diff --git a/config/_examples/_nsu/settings.json b/config/_examples/_nsu/settings.json new file mode 100644 index 000000000..ed3cba899 --- /dev/null +++ b/config/_examples/_nsu/settings.json @@ -0,0 +1,8 @@ +{ + "type": "nsu", + "email": "$creds.nsu_login", + "password": "$creds.nsu_password", + "olympiadId": 228, + "tourId": 11881, + "url": "https://olympic.nsu.ru/nsuts-new" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef59b069b..5dd1e9efd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ graphql = "7.0.2" # https://github.com/ExpediaGroup/graphql-kot ktor-client-cio = { version.ref = "ktor", group = "io.ktor", name = "ktor-client-cio" } ktor-client-auth = { version.ref = "ktor", group = "io.ktor", name = "ktor-client-auth" } +ktor-client-contentNegotiation = { version.ref = "ktor", group = "io.ktor", name = "ktor-client-content-negotiation" } ktor-serialization-kotlinx-json = { version.ref = "ktor", group = "io.ktor", name = "ktor-serialization-kotlinx-json" } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index b46bf3dff..d0ce11c18 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -404,6 +404,49 @@ ], "title": "noop" }, + "nsu": { + "type": "object", + "properties": { + "type": { + "const": "nsu", + "default": "nsu" + }, + "url": { + "type": "string" + }, + "olympiadId": { + "type": "number" + }, + "tourId": { + "type": "number" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "emulation": { + "$ref": "#/$defs/org.icpclive.cds.settings.EmulationSettings?" + }, + "network": { + "$ref": "#/$defs/org.icpclive.cds.settings.NetworkSettings?" + } + }, + "additionalProperties": false, + "required": [ + "type", + "url", + "olympiadId", + "tourId", + "email", + "password" + ], + "title": "nsu" + }, "pcms": { "type": "object", "properties": { @@ -534,6 +577,9 @@ { "$ref": "#/$defs/noop" }, + { + "$ref": "#/$defs/nsu" + }, { "$ref": "#/$defs/pcms" }, diff --git a/src/backend/src/main/kotlin/org/icpclive/Application.kt b/src/backend/src/main/kotlin/org/icpclive/Application.kt index 2665be834..e34832f20 100644 --- a/src/backend/src/main/kotlin/org/icpclive/Application.kt +++ b/src/backend/src/main/kotlin/org/icpclive/Application.kt @@ -18,6 +18,7 @@ import io.ktor.server.websocket.* import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement diff --git a/src/cds/build.gradle.kts b/src/cds/build.gradle.kts index cd1f3031a..c62e7dae4 100644 --- a/src/cds/build.gradle.kts +++ b/src/cds/build.gradle.kts @@ -90,6 +90,8 @@ dependencies { implementation(libs.kotlinx.serialization.json5) implementation(libs.ktor.client.auth) implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.protobuf) implementation(libs.graphql.ktor.client) runtimeOnly(libs.grpc.netty) diff --git a/src/cds/src/main/kotlin/org/icpclive/cds/nsu/NSUDataSource.kt b/src/cds/src/main/kotlin/org/icpclive/cds/nsu/NSUDataSource.kt new file mode 100644 index 000000000..005ffdc39 --- /dev/null +++ b/src/cds/src/main/kotlin/org/icpclive/cds/nsu/NSUDataSource.kt @@ -0,0 +1,213 @@ +package org.icpclive.cds.nsu + + +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.cookies.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.datetime.Instant +import kotlinx.datetime.toInstant +import kotlinx.datetime.toKotlinLocalDateTime +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import org.icpclive.api.* +import org.icpclive.cds.common.ContestParseResult +import org.icpclive.cds.common.FullReloadContestDataSource +import org.icpclive.cds.common.defaultHttpClient +import org.icpclive.cds.settings.NSUSettings +import java.time.format.DateTimeFormatter +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + + +internal class NSUDataSource(val settings: NSUSettings) : FullReloadContestDataSource(5.seconds) { + + val format = Json { ignoreUnknownKeys = true } + + @Serializable + class Credentials( + val email: String, + val password: String, + val method: String = "internal" + ) + + @Serializable + class Team( + val id: Int, + val title: String + ) + + @Serializable + class Task( + val id: Int, + val title: String + ) + + @Serializable + class TourTimes( + val tour_start_time: String, + val tour_end_time: String, + val rating_freeze_time: String? + ) + + override suspend fun loadOnce(): ContestParseResult { + val queueLimit = 999999 + val loginUrl = "${settings.url}/api/login" + val selectOlympiadUrl = "${settings.url}/api/olympiads/enter" + val selectTourUrl = "${settings.url}/api/tours/enter?tour=${settings.tourId}" + val submissionsUrl = "${settings.url}/api/queue/submissions?limit=" + queueLimit.toString() + val filtersUrl = "${settings.url}/api/queue/filters" + val ratingUrl = "${settings.url}/api/rating/rating?showFrozen=0" + + + httpClient.post(loginUrl) { + contentType(ContentType.Application.Json) + setBody(Credentials(settings.email.value, settings.password.value)) + }.bodyAsText() + + httpClient.post(selectOlympiadUrl) { + contentType(ContentType.Application.Json) + setBody(mapOf("olympiad" to settings.olympiadId.toString())) + }.bodyAsText() + + httpClient.get(selectTourUrl).bodyAsText() + + val queue: JsonObject = httpClient.get(submissionsUrl) { + contentType(ContentType.Application.Json) + }.body() + + + val submissions: List = format.decodeFromJsonElement(queue["submissions"] as JsonArray) + val filters: JsonObject = httpClient.get(filtersUrl).body() + val teams: List = format.decodeFromJsonElement(filters["teams"] as JsonArray) + val tasks: List = format.decodeFromJsonElement(filters["tasks"] as JsonArray) + + val teamList: List = teams.map { + TeamInfo( + id = it.id, + fullName = it.title, + displayName = it.title, + contestSystemId = it.id.toString(), + groups = emptyList(), + hashTag = null, + medias = emptyMap(), + isHidden = false, + isOutOfContest = false, + organizationId = null, + ) + }.sortedBy { it.id } + + + val problemsList: List = tasks.mapIndexed { index, it -> + ProblemInfo( + fullName = it.title, + displayName = it.title.substringBefore('.'), + id = it.id, + ordinal = index, + contestSystemId = it.id.toString() + ) + } + + val rating: JsonObject = httpClient.post(ratingUrl) { + contentType(ContentType.Application.Json) + setBody(mapOf("selectedAttributes" to emptyList().toString())) + }.body() + + val timeSettings: TourTimes = format.decodeFromJsonElement(rating["tourTimes"] as JsonObject) + val contestName: String = rating["tourTitle"]?.jsonPrimitive.toString() + + + val startTime = parseNSUTime(timeSettings.tour_start_time) + val contestLength = parseNSUTime(timeSettings.tour_end_time) - startTime + + val freezeTime: Duration = if (timeSettings.rating_freeze_time != null) { + parseNSUTime(timeSettings.rating_freeze_time) - startTime + } else { + contestLength + } + + val info = ContestInfo( + name = contestName, + status = ContestStatus.byCurrentTime(startTime, contestLength), + resultType = ContestResultType.ICPC, + startTime = startTime, + contestLength = contestLength, + freezeTime = freezeTime, + problemList = problemsList, + teamList = teamList, + groupList = emptyList(), + organizationList = emptyList(), + penaltyRoundingMode = PenaltyRoundingMode.EACH_SUBMISSION_DOWN_TO_MINUTE + ) + + val runs: List = submissions.map { + RunInfo( + id = it.id, + result = getRunResult(it.res, it.status), + problemId = it.taskId, + teamId = it.teamId, + percentage = 0.0, + time = parseNSUTime(it.smtime) - startTime + ) + } + + return ContestParseResult(info, runs, emptyList()) + + } + + private val httpClient = defaultHttpClient(null, settings.network) { + install(HttpCookies) + install(ContentNegotiation) { json() } + } + + private fun getRunResult(result: String?, status: Int): RunResult? { + val verdict = when (val letter = result?.last()) { + 'A' -> Verdict.Accepted + 'C' -> Verdict.CompilationError + // "Deadlock - Timeout": astronomical time limit exceeded + 'D' -> Verdict.TimeLimitExceeded + 'M' -> Verdict.MemoryLimitExceeded + // "No output file" + 'O' -> Verdict.PresentationError + 'P' -> Verdict.PresentationError + 'S' -> Verdict.SecurityViolation + 'R' -> Verdict.RuntimeError + 'T' -> Verdict.TimeLimitExceeded + 'W' -> Verdict.WrongAnswer + // "Static Analysis Failed" + 'X' -> Verdict.CompilationError + // "Dynamic Analysis failed" + 'Y' -> Verdict.CompilationErrorWithPenalty + '.' -> Verdict.Ignored + 'F', 'J', 'K' -> null + else -> error("Unknown verdict: $letter") + } + if (verdict == Verdict.Accepted && status != 3) return null + return verdict?.toRunResult() + } + + private val timePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + private fun parseNSUTime(time: String): Instant { + return java.time.LocalDateTime.parse( + time, timePattern + ).toKotlinLocalDateTime() + .toInstant(settings.timeZone) + } + + @Serializable + @Suppress("unused") + class Submission( + val id: Int, + val teamId: Int, + val smtime: String, + val status: Int, + val res: String?, + val taskId: Int + ) +} + + diff --git a/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt b/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt index d24aa809c..f5628c813 100644 --- a/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt +++ b/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt @@ -26,6 +26,7 @@ import org.icpclive.cds.common.isHttpUrl import org.icpclive.cds.ejudge.EjudgeDataSource import org.icpclive.cds.eolymp.EOlympDataSource import org.icpclive.cds.krsu.KRSUDataSource +import org.icpclive.cds.nsu.NSUDataSource import org.icpclive.cds.noop.NoopDataSource import org.icpclive.cds.pcms.PCMSDataSource import org.icpclive.cds.testsys.TestSysDataSource @@ -149,6 +150,22 @@ public class KRSUSettings( override fun toDataSource() = KRSUDataSource(this) } +@Serializable +@SerialName("nsu") +public class NSUSettings( + public val url: String, + public val olympiadId: Int, + public val tourId: Int, + public val email: Credential, + public val password: Credential, + @Serializable(with = TimeZoneSerializer::class) + public val timeZone: TimeZone = TimeZone.of("Asia/Novosibirsk"), + override val emulation: EmulationSettings? = null, + override val network: NetworkSettings? = null +) : CDSSettings() { + override fun toDataSource() = NSUDataSource(this) +} + @Serializable @SerialName("ejudge") public class EjudgeSettings(