diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/Public.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/Public.kt index 37a49fa..f7fb720 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/Public.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/Public.kt @@ -16,6 +16,7 @@ import com.nisemoe.generated.sequences.USERS_USER_ID_SEQ import com.nisemoe.generated.sequences.USERS_USER_ID_SEQ1 import com.nisemoe.generated.tables.Beatmaps import com.nisemoe.generated.tables.FlywaySchemaHistory +import com.nisemoe.generated.tables.OsuApiKeys import com.nisemoe.generated.tables.RedditPost import com.nisemoe.generated.tables.Scores import com.nisemoe.generated.tables.ScoresJudgements @@ -54,6 +55,11 @@ open class Public : SchemaImpl("public", DefaultCatalog.DEFAULT_CATALOG) { */ val FLYWAY_SCHEMA_HISTORY: FlywaySchemaHistory get() = FlywaySchemaHistory.FLYWAY_SCHEMA_HISTORY + /** + * The table public.osu_api_keys. + */ + val OSU_API_KEYS: OsuApiKeys get() = OsuApiKeys.OSU_API_KEYS + /** * The table public.reddit_post. */ @@ -102,6 +108,7 @@ open class Public : SchemaImpl("public", DefaultCatalog.DEFAULT_CATALOG) { override fun getTables(): List> = listOf( Beatmaps.BEATMAPS, FlywaySchemaHistory.FLYWAY_SCHEMA_HISTORY, + OsuApiKeys.OSU_API_KEYS, RedditPost.REDDIT_POST, Scores.SCORES, ScoresJudgements.SCORES_JUDGEMENTS, diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/keys/Keys.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/keys/Keys.kt index a81784d..4dd9612 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/keys/Keys.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/keys/Keys.kt @@ -6,6 +6,7 @@ package com.nisemoe.generated.keys import com.nisemoe.generated.tables.Beatmaps import com.nisemoe.generated.tables.FlywaySchemaHistory +import com.nisemoe.generated.tables.OsuApiKeys import com.nisemoe.generated.tables.RedditPost import com.nisemoe.generated.tables.Scores import com.nisemoe.generated.tables.ScoresJudgements @@ -14,6 +15,7 @@ import com.nisemoe.generated.tables.UpdateUserQueue import com.nisemoe.generated.tables.Users import com.nisemoe.generated.tables.records.BeatmapsRecord import com.nisemoe.generated.tables.records.FlywaySchemaHistoryRecord +import com.nisemoe.generated.tables.records.OsuApiKeysRecord import com.nisemoe.generated.tables.records.RedditPostRecord import com.nisemoe.generated.tables.records.ScoresJudgementsRecord import com.nisemoe.generated.tables.records.ScoresRecord @@ -34,6 +36,7 @@ import org.jooq.impl.Internal val BEATMAPS_PKEY: UniqueKey = Internal.createUniqueKey(Beatmaps.BEATMAPS, DSL.name("beatmaps_pkey"), arrayOf(Beatmaps.BEATMAPS.BEATMAP_ID), true) val FLYWAY_SCHEMA_HISTORY_PK: UniqueKey = Internal.createUniqueKey(FlywaySchemaHistory.FLYWAY_SCHEMA_HISTORY, DSL.name("flyway_schema_history_pk"), arrayOf(FlywaySchemaHistory.FLYWAY_SCHEMA_HISTORY.INSTALLED_RANK), true) +val OSU_API_KEYS_PKEY: UniqueKey = Internal.createUniqueKey(OsuApiKeys.OSU_API_KEYS, DSL.name("osu_api_keys_pkey"), arrayOf(OsuApiKeys.OSU_API_KEYS.ID), true) val REDDIT_POST_PKEY: UniqueKey = Internal.createUniqueKey(RedditPost.REDDIT_POST, DSL.name("reddit_post_pkey"), arrayOf(RedditPost.REDDIT_POST.POST_ID), true) val REPLAY_ID_UNIQUE: UniqueKey = Internal.createUniqueKey(Scores.SCORES, DSL.name("replay_id_unique"), arrayOf(Scores.SCORES.REPLAY_ID), true) val SCORES_PKEY: UniqueKey = Internal.createUniqueKey(Scores.SCORES, DSL.name("scores_pkey"), arrayOf(Scores.SCORES.ID), true) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/OsuApiKeys.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/OsuApiKeys.kt new file mode 100644 index 0000000..444bca0 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/OsuApiKeys.kt @@ -0,0 +1,147 @@ +/* + * This file is generated by jOOQ. + */ +package com.nisemoe.generated.tables + + +import com.nisemoe.generated.Public +import com.nisemoe.generated.keys.OSU_API_KEYS_PKEY +import com.nisemoe.generated.tables.records.OsuApiKeysRecord + +import java.time.OffsetDateTime +import java.util.function.Function + +import org.jooq.Field +import org.jooq.ForeignKey +import org.jooq.Identity +import org.jooq.Name +import org.jooq.Record +import org.jooq.Records +import org.jooq.Row5 +import org.jooq.Schema +import org.jooq.SelectField +import org.jooq.Table +import org.jooq.TableField +import org.jooq.TableOptions +import org.jooq.UniqueKey +import org.jooq.impl.DSL +import org.jooq.impl.Internal +import org.jooq.impl.SQLDataType +import org.jooq.impl.TableImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("UNCHECKED_CAST") +open class OsuApiKeys( + alias: Name, + child: Table?, + path: ForeignKey?, + aliased: Table?, + parameters: Array?>? +): TableImpl( + alias, + Public.PUBLIC, + child, + path, + aliased, + parameters, + DSL.comment(""), + TableOptions.table() +) { + companion object { + + /** + * The reference instance of public.osu_api_keys + */ + val OSU_API_KEYS: OsuApiKeys = OsuApiKeys() + } + + /** + * The class holding records for this type + */ + override fun getRecordType(): Class = OsuApiKeysRecord::class.java + + /** + * The column public.osu_api_keys.id. + */ + val ID: TableField = createField(DSL.name("id"), SQLDataType.INTEGER.nullable(false).identity(true), this, "") + + /** + * The column public.osu_api_keys.api_key. + */ + val API_KEY: TableField = createField(DSL.name("api_key"), SQLDataType.CLOB.nullable(false), this, "") + + /** + * The column public.osu_api_keys.created_at. + */ + val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "") + + /** + * The column public.osu_api_keys.is_valid. + */ + val IS_VALID: TableField = createField(DSL.name("is_valid"), SQLDataType.BOOLEAN.nullable(false).defaultValue(DSL.field(DSL.raw("true"), SQLDataType.BOOLEAN)), this, "") + + /** + * The column public.osu_api_keys.is_active. + */ + val IS_ACTIVE: TableField = createField(DSL.name("is_active"), SQLDataType.BOOLEAN.nullable(false).defaultValue(DSL.field(DSL.raw("true"), SQLDataType.BOOLEAN)), this, "") + + private constructor(alias: Name, aliased: Table?): this(alias, null, null, aliased, null) + private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, aliased, parameters) + + /** + * Create an aliased public.osu_api_keys table reference + */ + constructor(alias: String): this(DSL.name(alias)) + + /** + * Create an aliased public.osu_api_keys table reference + */ + constructor(alias: Name): this(alias, null) + + /** + * Create a public.osu_api_keys table reference + */ + constructor(): this(DSL.name("osu_api_keys"), null) + + constructor(child: Table, key: ForeignKey): this(Internal.createPathAlias(child, key), child, key, OSU_API_KEYS, null) + override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC + override fun getIdentity(): Identity = super.getIdentity() as Identity + override fun getPrimaryKey(): UniqueKey = OSU_API_KEYS_PKEY + override fun `as`(alias: String): OsuApiKeys = OsuApiKeys(DSL.name(alias), this) + override fun `as`(alias: Name): OsuApiKeys = OsuApiKeys(alias, this) + override fun `as`(alias: Table<*>): OsuApiKeys = OsuApiKeys(alias.getQualifiedName(), this) + + /** + * Rename this table + */ + override fun rename(name: String): OsuApiKeys = OsuApiKeys(DSL.name(name), null) + + /** + * Rename this table + */ + override fun rename(name: Name): OsuApiKeys = OsuApiKeys(name, null) + + /** + * Rename this table + */ + override fun rename(name: Table<*>): OsuApiKeys = OsuApiKeys(name.getQualifiedName(), null) + + // ------------------------------------------------------------------------- + // Row5 type methods + // ------------------------------------------------------------------------- + override fun fieldsRow(): Row5 = super.fieldsRow() as Row5 + + /** + * Convenience mapping calling {@link SelectField#convertFrom(Function)}. + */ + fun mapping(from: (Int?, String?, OffsetDateTime?, Boolean?, Boolean?) -> U): SelectField = convertFrom(Records.mapping(from)) + + /** + * Convenience mapping calling {@link SelectField#convertFrom(Class, + * Function)}. + */ + fun mapping(toType: Class, from: (Int?, String?, OffsetDateTime?, Boolean?, Boolean?) -> U): SelectField = convertFrom(toType, Records.mapping(from)) +} diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/UpdateUserQueue.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/UpdateUserQueue.kt index 1ca5ca8..6ea4e02 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/UpdateUserQueue.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/UpdateUserQueue.kt @@ -87,7 +87,7 @@ open class UpdateUserQueue( /** * The column public.update_user_queue.processed_at. */ - val PROCESSED_AT: TableField = createField(DSL.name("processed_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "") + val PROCESSED_AT: TableField = createField(DSL.name("processed_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "") /** * The column public.update_user_queue.progress_current. diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/OsuApiKeysRecord.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/OsuApiKeysRecord.kt new file mode 100644 index 0000000..9b7082d --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/OsuApiKeysRecord.kt @@ -0,0 +1,121 @@ +/* + * This file is generated by jOOQ. + */ +package com.nisemoe.generated.tables.records + + +import com.nisemoe.generated.tables.OsuApiKeys + +import java.time.OffsetDateTime + +import org.jooq.Field +import org.jooq.Record1 +import org.jooq.Record5 +import org.jooq.Row5 +import org.jooq.impl.UpdatableRecordImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("UNCHECKED_CAST") +open class OsuApiKeysRecord private constructor() : UpdatableRecordImpl(OsuApiKeys.OSU_API_KEYS), Record5 { + + open var id: Int? + set(value): Unit = set(0, value) + get(): Int? = get(0) as Int? + + open var apiKey: String + set(value): Unit = set(1, value) + get(): String = get(1) as String + + open var createdAt: OffsetDateTime? + set(value): Unit = set(2, value) + get(): OffsetDateTime? = get(2) as OffsetDateTime? + + @Suppress("INAPPLICABLE_JVM_NAME") + @set:JvmName("setIsValid") + open var isValid: Boolean? + set(value): Unit = set(3, value) + get(): Boolean? = get(3) as Boolean? + + @Suppress("INAPPLICABLE_JVM_NAME") + @set:JvmName("setIsActive") + open var isActive: Boolean? + set(value): Unit = set(4, value) + get(): Boolean? = get(4) as Boolean? + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + override fun key(): Record1 = super.key() as Record1 + + // ------------------------------------------------------------------------- + // Record5 type implementation + // ------------------------------------------------------------------------- + + override fun fieldsRow(): Row5 = super.fieldsRow() as Row5 + override fun valuesRow(): Row5 = super.valuesRow() as Row5 + override fun field1(): Field = OsuApiKeys.OSU_API_KEYS.ID + override fun field2(): Field = OsuApiKeys.OSU_API_KEYS.API_KEY + override fun field3(): Field = OsuApiKeys.OSU_API_KEYS.CREATED_AT + override fun field4(): Field = OsuApiKeys.OSU_API_KEYS.IS_VALID + override fun field5(): Field = OsuApiKeys.OSU_API_KEYS.IS_ACTIVE + override fun component1(): Int? = id + override fun component2(): String = apiKey + override fun component3(): OffsetDateTime? = createdAt + override fun component4(): Boolean? = isValid + override fun component5(): Boolean? = isActive + override fun value1(): Int? = id + override fun value2(): String = apiKey + override fun value3(): OffsetDateTime? = createdAt + override fun value4(): Boolean? = isValid + override fun value5(): Boolean? = isActive + + override fun value1(value: Int?): OsuApiKeysRecord { + set(0, value) + return this + } + + override fun value2(value: String?): OsuApiKeysRecord { + set(1, value) + return this + } + + override fun value3(value: OffsetDateTime?): OsuApiKeysRecord { + set(2, value) + return this + } + + override fun value4(value: Boolean?): OsuApiKeysRecord { + set(3, value) + return this + } + + override fun value5(value: Boolean?): OsuApiKeysRecord { + set(4, value) + return this + } + + override fun values(value1: Int?, value2: String?, value3: OffsetDateTime?, value4: Boolean?, value5: Boolean?): OsuApiKeysRecord { + this.value1(value1) + this.value2(value2) + this.value3(value3) + this.value4(value4) + this.value5(value5) + return this + } + + /** + * Create a detached, initialised OsuApiKeysRecord + */ + constructor(id: Int? = null, apiKey: String, createdAt: OffsetDateTime? = null, isValid: Boolean? = null, isActive: Boolean? = null): this() { + this.id = id + this.apiKey = apiKey + this.createdAt = createdAt + this.isValid = isValid + this.isActive = isActive + resetChangedOnNotNull() + } +} diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/references/Tables.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/references/Tables.kt index 35ebc13..a521b55 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/references/Tables.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/references/Tables.kt @@ -6,6 +6,7 @@ package com.nisemoe.generated.tables.references import com.nisemoe.generated.tables.Beatmaps import com.nisemoe.generated.tables.FlywaySchemaHistory +import com.nisemoe.generated.tables.OsuApiKeys import com.nisemoe.generated.tables.RedditPost import com.nisemoe.generated.tables.Scores import com.nisemoe.generated.tables.ScoresJudgements @@ -25,6 +26,11 @@ val BEATMAPS: Beatmaps = Beatmaps.BEATMAPS */ val FLYWAY_SCHEMA_HISTORY: FlywaySchemaHistory = FlywaySchemaHistory.FLYWAY_SCHEMA_HISTORY +/** + * The table public.osu_api_keys. + */ +val OSU_API_KEYS: OsuApiKeys = OsuApiKeys.OSU_API_KEYS + /** * The table public.reddit_post. */ diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt index 0ab130e..e0e5f96 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt @@ -1,25 +1,65 @@ package com.nisemoe.nise.osu +import com.nisemoe.generated.tables.references.OSU_API_KEYS import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json +import org.jooq.DSLContext import org.slf4j.LoggerFactory +import org.springframework.beans.factory.InitializingBean import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.util.concurrent.atomic.AtomicInteger + +class InvalidOsuApiKeyException() : Exception() @Service class OsuApi( - private val tokenService: TokenService -) { + private val tokenService: TokenService, + private val dslContext: DSLContext +): InitializingBean { private val logger = LoggerFactory.getLogger(javaClass) - @Value("\${OSU_API_KEY}") - private lateinit var osuApiKey: String + private lateinit var apiKeysList: List + private val currentKeyIndex = AtomicInteger(0) + + override fun afterPropertiesSet() { + this.loadApiKeys() + } + + fun loadApiKeys() { + currentKeyIndex.set(0) + + val keys = dslContext.select(OSU_API_KEYS.API_KEY) + .from(OSU_API_KEYS) + .where(OSU_API_KEYS.IS_VALID.eq(true)) + .and(OSU_API_KEYS.IS_ACTIVE.eq(true)) + .fetchInto(String::class.java) + + this.apiKeysList = keys + this.logger.info("Loaded ${keys.size} valid API keys") + } + + fun getCurrentApiKey(): String { + val key = apiKeysList[currentKeyIndex.get() % apiKeysList.size] + currentKeyIndex.getAndIncrement() + return key + } + + fun setKeyAsInvalid(apiKey: String) { + dslContext.update(OSU_API_KEYS) + .set(OSU_API_KEYS.IS_VALID, false) + .where(OSU_API_KEYS.API_KEY.eq(apiKey)) + .execute() + + this.logger.info("Marked API key $apiKey as invalid") + this.loadApiKeys() + } @OptIn(ExperimentalSerializationApi::class) private val serializer = Json { ignoreUnknownKeys = true; explicitNulls = false } @@ -57,29 +97,41 @@ class OsuApi( return this.sendWithRetry(request) } + /** + * Retrieves the replay data for a given score ID from the Osu API. + * Efficiently cycles through the API keys to avoid rate limiting. + * It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data + * + * @param scoreId The ID of the score (best_id) + */ fun getReplay(scoreId: Long): OsuApiModels.ReplayResponse? { - val queryParams = mapOf( - "k" to this.osuApiKey, - "s" to scoreId, - "m" to 0 // [osu!std] - ) - val response = this.doRequest("https://osu.ppy.sh/api/get_replay?", queryParams) - if(response == null) { - this.logger.info("Error loading replay data") - return null + while (apiKeysList.isNotEmpty()) { + val apiKey = this.getCurrentApiKey() + val queryParams = mapOf( + "k" to apiKey, + "s" to scoreId.toString(), + "m" to "0" // [osu!std] + ) + + try { + val response = this.doRequest("https://osu.ppy.sh/api/get_replay?", queryParams) + if (response != null && response.statusCode() == 200) { + try { + return serializer.decodeFromString(OsuApiModels.ReplayResponse.serializer(), response.body()) + } catch(exception: Exception) { + this.logger.error("Failed to parse successful response: ${response.body()}") + this.logger.error(exception.stackTraceToString()) + break + } + } + } catch (e: InvalidOsuApiKeyException) { + this.logger.error("Invalid API key detected: $apiKey, trying next...") + this.setKeyAsInvalid(apiKey) + } } - return if (response.statusCode() == 200) { - try { - serializer.decodeFromString(OsuApiModels.ReplayResponse.serializer(), response.body()) - } catch(exception: Exception) { - this.logger.error(response.body()) - this.logger.error(exception.stackTraceToString()) - return null - } - } else { - null - } + this.logger.error("Failed to load replay data after cycling through all keys.") + return null } fun getTopBeatmapScores(beatmapId: Int): OsuApiModels.BeatmapScores? { @@ -213,6 +265,11 @@ class OsuApi( this.logger.debug("Result: {}", response.statusCode()) this.logger.debug("") + // Handle osu!api v1 invalid api key errors in a specific way; mark the key as invalid so that + // it won't be used again and return null to indicate that the request failed. + if(response.statusCode() == 401 && response.body() == "{\"error\":\"Please provide a valid API key.\"}") + throw InvalidOsuApiKeyException() + when(response.statusCode()) { 401 -> { // If the status code is 401, the access token has expired or something, refresh it. diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/GlobalCache.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/GlobalCache.kt index f6aa1a9..40a4a8a 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/GlobalCache.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/GlobalCache.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import org.springframework.util.StopWatch +import kotlin.math.roundToInt @Service class GlobalCache( @@ -27,9 +29,12 @@ class GlobalCache( var statistics: Statistics? = null var rssFeed: RssFeed? = null + val stopwatch = StopWatch() + // 10 minutes to ms = 600000 @Scheduled(fixedDelay = 600000, initialDelay = 0) fun updateCaches() { + this.stopwatch.start() logger.info("Updating the cache!") runBlocking { @@ -43,6 +48,9 @@ class GlobalCache( similarReplays = similarReplaysDeferred.await() suspiciousScores = suspiciousScoresDeferred.await() } + + this.stopwatch.stop() + logger.info("Cache updated in {} seconds", String.format("%.2f", stopwatch.totalTimeSeconds)) } } \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt index 0dbab4f..b625d92 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt @@ -563,7 +563,7 @@ class ImportScores( // It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data // So, we sleep for 6 seconds. - Thread.sleep(6000) +// Thread.sleep(6000) // Calculate UR val processedReplay: CircleguardService.ReplayResponse? = try { diff --git a/nise-backend/src/main/resources/db/migration/V0.0.1.019__create_osu_api_keys.sql b/nise-backend/src/main/resources/db/migration/V0.0.1.019__create_osu_api_keys.sql new file mode 100644 index 0000000..f7f7e40 --- /dev/null +++ b/nise-backend/src/main/resources/db/migration/V0.0.1.019__create_osu_api_keys.sql @@ -0,0 +1,8 @@ +create table "public".osu_api_keys +( + id serial primary key, + api_key text not null, + created_at timestamp with time zone not null default current_timestamp, + is_valid boolean not null default true, + is_active boolean not null default true +); \ No newline at end of file diff --git a/nise-frontend/src/app/view-user/view-user.component.html b/nise-frontend/src/app/view-user/view-user.component.html index 5210cc8..88e6a61 100644 --- a/nise-frontend/src/app/view-user/view-user.component.html +++ b/nise-frontend/src/app/view-user/view-user.component.html @@ -56,7 +56,7 @@
updating now! | progress: {{ this.userInfo.queue_details.progressCurrent != null ? this.userInfo.queue_details.progressCurrent : "?" }}/{{ this.userInfo.queue_details.progressTotal != null ? this.userInfo.queue_details.progressTotal : "?" }} - (in queue) + (in queue, be patient)