diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingHttpApi.kt new file mode 100644 index 0000000..bb7bc0b --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingHttpApi.kt @@ -0,0 +1,36 @@ +package ru.vityaman.lms.botalka.api.http.endpoint + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RestController +import ru.vityaman.lms.botalka.api.http.server.GetRatingGrades200ResponseMessage +import ru.vityaman.lms.botalka.api.http.server.HomeworkGradeMessage +import ru.vityaman.lms.botalka.api.http.server.StudentGradesMessage +import ru.vityaman.lms.botalka.api.http.server.apis.RatingApi +import ru.vityaman.lms.botalka.logic.RatingService + +typealias RatingGrades = GetRatingGrades200ResponseMessage + +@RestController +class RatingHttpApi( + @Autowired private val service: RatingService, +) : RatingApi { + override suspend fun getRatingGrades(): ResponseEntity = + ResponseEntity.ok( + RatingGrades( + grades = service.grades().map { (student, grades) -> + println("$student $grades") + StudentGradesMessage( + studentId = student.id.number, + grades = grades.entries + .map { (homework, grade) -> + HomeworkGradeMessage( + homeworkId = homework.number, + score = grade.value.toInt(), + ) + }, + ) + }, + ), + ) +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt index ac38951..e46c500 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt @@ -1,9 +1,15 @@ package ru.vityaman.lms.botalka.commons +import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.produceIn +private suspend fun ReceiveChannel.receiveIfNotEmpty(): T? = + this.receiveCatching().also { + it.exceptionOrNull()?.let { throw it } + }.getOrNull() + fun > mergeOrdered( lhs: Flow, rhs: Flow, @@ -12,26 +18,26 @@ fun > mergeOrdered( val lhsChan = lhs.produceIn(this) val rhsChan = rhs.produceIn(this) - var left = lhsChan.receiveCatching().getOrNull() - var right = rhsChan.receiveCatching().getOrNull() + var left = lhsChan.receiveIfNotEmpty() + var right = rhsChan.receiveIfNotEmpty() while (left != null && right != null) { if (key(left) < key(right)) { send(left) - left = lhsChan.receiveCatching().getOrNull() + left = lhsChan.receiveIfNotEmpty() } else { send(right) - right = rhsChan.receiveCatching().getOrNull() + right = rhsChan.receiveIfNotEmpty() } } while (left != null) { send(left) - left = lhsChan.receiveCatching().getOrNull() + left = lhsChan.receiveIfNotEmpty() } while (right != null) { send(right) - right = rhsChan.receiveCatching().getOrNull() + right = rhsChan.receiveIfNotEmpty() } } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/RatingService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/RatingService.kt new file mode 100644 index 0000000..69b8d88 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/RatingService.kt @@ -0,0 +1,10 @@ +package ru.vityaman.lms.botalka.logic + +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student + +typealias Grades = Map> + +interface RatingService { + suspend fun grades(): Grades +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt index 69cb582..eb9ad70 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt @@ -1,12 +1,18 @@ package ru.vityaman.lms.botalka.logic import kotlinx.coroutines.flow.Flow +import ru.vityaman.lms.botalka.domain.model.Homework import ru.vityaman.lms.botalka.domain.model.Workspace interface WorkspaceService { fun events(id: Workspace.Id): Flow + suspend fun produce( id: Workspace.Id, event: Workspace.Event.Draft, ): Workspace.Event + + suspend fun grade(id: Workspace.Id): Homework.Score? + + fun all(): Flow } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicRatingService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicRatingService.kt new file mode 100644 index 0000000..bd80b11 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicRatingService.kt @@ -0,0 +1,34 @@ +package ru.vityaman.lms.botalka.logic.basic + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.toList +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student +import ru.vityaman.lms.botalka.logic.Grades +import ru.vityaman.lms.botalka.logic.RatingService +import ru.vityaman.lms.botalka.logic.WorkspaceService + +@Service +class BasicRatingService( + @Autowired private val workspaces: WorkspaceService, +) : RatingService { + override suspend fun grades(): Grades { + val byStuds = mutableMapOf< + Student, + MutableMap, + >() + + coroutineScope { + for (id in workspaces.all().toList()) { + workspaces.grade(id)?.let { grade -> + val byHws = byStuds.getOrPut(id.student) { mutableMapOf() } + byHws[id.homework] = grade + } + } + } + + return byStuds + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt index 50ea24a..7208e57 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt @@ -1,8 +1,12 @@ package ru.vityaman.lms.botalka.logic.basic import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.map import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.domain.model.Homework import ru.vityaman.lms.botalka.domain.model.Workspace import ru.vityaman.lms.botalka.logic.WorkspaceService import ru.vityaman.lms.botalka.storage.WorkspaceStorage @@ -23,4 +27,15 @@ class BasicWorkspaceService( is Workspace.Submission.Draft -> storage.save(id, event) is Workspace.Feedback.Draft -> storage.save(id, event) } + + override suspend fun grade(id: Workspace.Id): Homework.Score? = + events(id) + .map { it as? Workspace.Feedback } + .filterNotNull() + .map { it.score } + .filterNotNull() + .lastOrNull() + + override fun all(): Flow = + storage.all() } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt index 05dcb25..43d2ce6 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt @@ -20,4 +20,6 @@ interface WorkspaceStorage { id: Workspace.Id, event: Workspace.Feedback.Draft, ): Workspace.Feedback + + fun all(): Flow } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt index 3f5561c..4686de1 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt @@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository import reactor.kotlin.core.publisher.toMono import ru.vityaman.lms.botalka.commons.mergeOrdered +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student +import ru.vityaman.lms.botalka.domain.model.User import ru.vityaman.lms.botalka.domain.model.Workspace import ru.vityaman.lms.botalka.storage.WorkspaceStorage import ru.vityaman.lms.botalka.storage.jooq.entity.toModel @@ -83,16 +86,31 @@ class JooqWorkspaceStorage( HOMEWORK_FEEDBACK.STUDENT_ID, HOMEWORK_FEEDBACK.TEACHER_ID, HOMEWORK_FEEDBACK.COMMENT, + HOMEWORK_FEEDBACK.SCORE, ) .values( id.homework.number, id.student.id.number, event.teacher.id.number, event.comment, + event.score?.value, ) .returning() .coerce(HOMEWORK_FEEDBACK) .toMono() .map { it.toModel() } .awaitSingle() + + override fun all(): Flow = + database.execute + .selectFrom(HOMEWORK_SUBMISSION) + .where(HOMEWORK_SUBMISSION.ID.eq(HOMEWORK_SUBMISSION.ID)) + .toFlux() + .map { + Workspace.Id( + Homework.Id(it.homeworkId), + Student(User.Id(it.studentId)), + ) + } + .asFlow() } diff --git a/botalka/src/main/resources/static/openapi/api.yml b/botalka/src/main/resources/static/openapi/api.yml index 5ee2a18..b5ad8bd 100644 --- a/botalka/src/main/resources/static/openapi/api.yml +++ b/botalka/src/main/resources/static/openapi/api.yml @@ -116,9 +116,14 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/StudentGrades' + type: object + properties: + grades: + type: array + items: + $ref: '#/components/schemas/StudentGrades' + required: + - grades /user/{id}: get: tags: [ User ] diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt index 42724e9..253f733 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean import org.springframework.stereotype.Component import ru.vityaman.lms.botalka.api.http.client.apis.HomeworkApi import ru.vityaman.lms.botalka.api.http.client.apis.MonitoringApi +import ru.vityaman.lms.botalka.api.http.client.apis.RatingApi import ru.vityaman.lms.botalka.api.http.client.apis.UserApi @Component @@ -18,4 +19,7 @@ class ApiClientSet { @Bean fun userApi() = UserApi(url) + + @Bean + fun ratingApi() = RatingApi(url) } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt index 5c15209..08fdc67 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt @@ -41,6 +41,7 @@ class HomeworkApiClient( WorkspaceFeedbackDraftMessage( kind = WorkspaceEventKindMessage.feedback, comment = draft.comment, + score = draft.score?.value?.toInt(), ), ).awaitSingle() } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt new file mode 100644 index 0000000..5c4a8ba --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt @@ -0,0 +1,143 @@ +package ru.vityaman.lms.botalka.api.http.endpoint + +import io.kotest.common.runBlocking +import io.kotest.matchers.maps.shouldContainExactly +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactor.awaitSingle +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import ru.vityaman.lms.botalka.BotalkaTestSuite +import ru.vityaman.lms.botalka.api.http.client.HomeworkApiClient +import ru.vityaman.lms.botalka.api.http.client.UserApiClient +import ru.vityaman.lms.botalka.api.http.client.apis.RatingApi +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student +import ru.vityaman.lms.botalka.domain.model.Teacher +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.Workspace +import java.time.OffsetDateTime + +class RatingApiTest( + @Autowired private val rating: RatingApi, + @Autowired private val homeworks: HomeworkApiClient, + @Autowired private val users: UserApiClient, +) : BotalkaTestSuite() { + private lateinit var teacher: Teacher + + private val dummyHomework = Homework.Draft( + Homework.Title("Title Any"), + Homework.Description("Description Any"), + Homework.Score(200), + OffsetDateTime.now(), + ) + + private fun dummySubmit(student: Student) = + Workspace.Submission.Draft( + producer = student.id, + note = "Dummy Note", + ) + + private fun dummyFeedback(teacher: Teacher, score: Homework.Score?) = + Workspace.Feedback.Draft( + teacher = teacher, + comment = "Dummy Comment", + score = score, + ) + + private suspend fun submit(homework: Homework.Id, student: Student) = + homeworks.submit( + Workspace.Id(homework, student), + dummySubmit(student), + ) + + private suspend fun feedback( + homework: Homework.Id, + student: Student, + score: Int?, + ) = + homeworks.feedback( + Workspace.Id(homework, student), + dummyFeedback( + teacher, + score?.let { + Homework.Score(it.toShort()) + }, + ), + ) + + @Test + @Suppress("LongMethod") + fun somethingWorks(): Unit = runBlocking { + teacher = Teacher(users.register(User.Alias("teacher"))) + + val (van, vit, sas) = coroutineScope { + arrayOf( + async { Student(users.register(User.Alias("vanya"))) }, + async { Student(users.register(User.Alias("vitya"))) }, + async { Student(users.register(User.Alias("sasha"))) }, + ).map { it.await() } + } + + coroutineScope { + launch { + users.approve(users.promote(teacher.id, User.Role.TEACHER)) + } + + launch { users.approve(users.promote(van.id, User.Role.STUDENT)) } + launch { users.approve(users.promote(vit.id, User.Role.STUDENT)) } + launch { users.approve(users.promote(sas.id, User.Role.STUDENT)) } + } + + val (hwA, hwB, hwC) = coroutineScope { + arrayOf( + async { homeworks.create(dummyHomework) }, + async { homeworks.create(dummyHomework) }, + async { homeworks.create(dummyHomework) }, + ).map { it.await() } + } + + coroutineScope { + launch { submit(hwA, van) } + launch { submit(hwB, van) } + + launch { submit(hwA, vit) } + launch { submit(hwB, vit) } + launch { submit(hwC, vit) } + + launch { submit(hwB, sas) } + } + + coroutineScope { + launch { feedback(hwA, van, null) } + launch { feedback(hwB, van, 56) } + + launch { feedback(hwA, vit, 21) } + launch { feedback(hwB, vit, null) } + launch { feedback(hwC, vit, 91) } + + launch { feedback(hwB, sas, 11) } + } + + val table = rating.getRatingGrades().awaitSingle() + .grades + .associateBy { it.studentId } + .mapValues { (_, value) -> + value.grades.associate { Pair(it.homeworkId, it.score) } + } + + table shouldContainExactly mapOf( + van.id.number to mapOf( + hwB.number to 56, + ), + vit.id.number to mapOf( + hwA.number to 21, + hwC.number to 91, + ), + sas.id.number to mapOf( + hwB.number to 11, + ), + ) + } +}