diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index c996a28..ac04148 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: - id: progress uri: ${LMS_PROGRESS_URI:http://localhost:8083} predicates: - - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/submissions/** + - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/** management: endpoints: diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index e11d2ae..ffc8537 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -128,12 +128,17 @@ data class GradeSetRequest( val studentId: String, val labId: String, val score: BigDecimal, + val comments: List = emptyList(), ) +data class CommentInput(val text: String, val tone: String = "neutral") +data class CommentOut(val text: String, val tone: String) + data class GradeResponse( val studentId: String, val labId: String, val score: BigDecimal, + val comments: List = emptyList(), ) data class SubmissionDto( diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index b4d0b1f..22b07a1 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -84,6 +84,10 @@ class ProgressController( fun myGrades(@AuthenticationPrincipal jwt: Jwt): List = service.listGrades().filter { it.studentId == jwt.subject } + @GetMapping("/comment-templates") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun suggestComments(@RequestParam q: String): List = service.suggestComments(q) + @PostMapping("/me/submissions/{labId}") fun submit( @PathVariable labId: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt index dc02f5a..dc9ce98 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -3,10 +3,14 @@ package ru.ysu.lms.progress import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.progress.domain.CommentTemplate +import ru.ysu.lms.progress.domain.GradeComment import ru.ysu.lms.progress.domain.LabGrade import ru.ysu.lms.progress.domain.LabGradeKey import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.VariantAssignment +import ru.ysu.lms.progress.repo.CommentTemplateRepository +import ru.ysu.lms.progress.repo.GradeCommentRepository import ru.ysu.lms.progress.repo.LabGradeRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository @@ -19,6 +23,8 @@ class ProgressService( private val levels: StudentLabLevelRepository, private val assignments: VariantAssignmentRepository, private val grades: LabGradeRepository, + private val templates: CommentTemplateRepository, + private val gradeComments: GradeCommentRepository, private val academic: AcademicClient, private val identity: IdentityClient, ) { @@ -180,16 +186,60 @@ class ProgressService( if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) { throw BadRequestException("Оценка должна быть в диапазоне 0–5") } - val key = LabGradeKey(UUID.fromString(request.studentId), UUID.fromString(request.labId)) + val sid = UUID.fromString(request.studentId) + val lid = UUID.fromString(request.labId) + val key = LabGradeKey(sid, lid) val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) } grade.score = request.score grades.save(grade) - return GradeResponse(request.studentId, request.labId, request.score) + replaceComments(sid, lid, request.comments) + return GradeResponse(request.studentId, request.labId, request.score, + commentsOf(sid, lid).map { CommentOut(it.text, it.tone) }) + } + + private fun replaceComments(sid: UUID, lid: UUID, inputs: List) { + val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet() + val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) } + if (existing.toSet() == incoming.toSet()) return + gradeComments.deleteByStudentIdAndLabId(sid, lid) + for ((text, tone) in incoming) { + gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone)) + val t = templates.findByText(text) + if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1)) + else { t.useCount += 1; t.tone = tone; templates.save(t) } + } + } + + private fun normalizeTone(tone: String): String = when (tone.lowercase()) { + "positive", "положительный", "позитивный", "pos" -> "positive" + "negative", "негативный", "отрицательный", "neg" -> "negative" + else -> "neutral" } @Transactional(readOnly = true) - fun listGrades(): List = - grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) } + fun commentsOf(sid: UUID, lid: UUID): List = gradeComments.findByStudentIdAndLabId(sid, lid) + + @Transactional(readOnly = true) + fun suggestComments(query: String): List = + templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) + .map { CommentOut(it.text, it.tone) } + + @Transactional(readOnly = true) + fun myComments(studentId: UUID): List = gradeComments.findByStudentId(studentId) + + @Transactional(readOnly = true) + fun listGrades(): List { + val byGrade = gradeComments.findAll() + .groupBy { it.studentId to it.labId } + return grades.findAllBy().map { + GradeResponse( + studentId = it.studentId.toString(), + labId = it.labId.toString(), + score = it.score, + comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) }, + ) + } + } @Transactional(readOnly = true) fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt new file mode 100644 index 0000000..90eb97e --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt @@ -0,0 +1,29 @@ +package ru.ysu.lms.progress.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import ru.ysu.lms.common.Ids +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "comment_templates") +class CommentTemplate( + @Id var id: UUID = Ids.v7(), + @Column(nullable = false, unique = true) var text: String = "", + @Column(nullable = false, length = 8) var tone: String = "neutral", + @Column(name = "use_count", nullable = false) var useCount: Int = 1, +) + +@Entity +@Table(name = "grade_comments") +class GradeComment( + @Id var id: UUID = Ids.v7(), + @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0), + @Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0), + @Column(nullable = false) var text: String = "", + @Column(nullable = false, length = 8) var tone: String = "neutral", + @Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(), +) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt new file mode 100644 index 0000000..413c8e1 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt @@ -0,0 +1,17 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.CommentTemplate +import ru.ysu.lms.progress.domain.GradeComment +import java.util.UUID + +interface CommentTemplateRepository : JpaRepository { + fun findByText(text: String): CommentTemplate? + fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List +} + +interface GradeCommentRepository : JpaRepository { + fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List + fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID) + fun findByStudentId(studentId: UUID): List +} diff --git a/services/progress-service/src/main/resources/db/migration/V6__grade_comments.sql b/services/progress-service/src/main/resources/db/migration/V6__grade_comments.sql new file mode 100644 index 0000000..4ac1fdd --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V6__grade_comments.sql @@ -0,0 +1,19 @@ +-- Grade comments: reusable templates (frequency-ranked) + per-grade comments. + +CREATE TABLE comment_templates ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + text text NOT NULL UNIQUE, + tone varchar(8) NOT NULL DEFAULT 'neutral', -- positive | neutral | negative + use_count integer NOT NULL DEFAULT 1 +); + +CREATE TABLE grade_comments ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + student_id uuid NOT NULL, + lab_id uuid NOT NULL, + text text NOT NULL, + tone varchar(8) NOT NULL DEFAULT 'neutral', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX grade_comments_grade_idx ON grade_comments (student_id, lab_id); \ No newline at end of file diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt index e0bfb65..a436be1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt @@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets class AdminController( private val api: ApiClient, private val markdown: ru.ysu.lms.markdown.MarkdownRenderer, + private val mapper: tools.jackson.databind.ObjectMapper, ) { @GetMapping @@ -119,6 +120,7 @@ class AdminController( val labs = api.listLabs(t) val grades = api.listGrades(t) val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } + val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } val studentGrades = group.students.map { s -> val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] } val nums = scores.mapNotNull { it?.toBigDecimalOrNull() } @@ -128,11 +130,17 @@ class AdminController( studentNo = s.studentNo, scores = scores, average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(), + comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }, + commentsJson = mapper.writeValueAsString(labs.map { lab -> + (commentsBy["${"$"}{s.id}|${"$"}{lab.id}"] ?: emptyList()).map { (t, tone) -> mapOf("text" to t, "tone" to tone) } + }), + ) } model.addAttribute("group", group) model.addAttribute("labs", labs) model.addAttribute("studentGrades", studentGrades) + model.addAttribute("commentOptions", api.suggestComments(t, "")) return "admin/group" } @@ -142,10 +150,12 @@ class AdminController( @RequestParam studentId: String, @RequestParam labId: String, @RequestParam score: String, + @RequestParam(required = false) commentsJson: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { - api.setGrade(token(session), GradeSetPayload(studentId, labId, score)) + val comments = parseComments(commentsJson ?: "[]") + api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments)) redirect.addFlashAttribute("notice", "Оценка сохранена") "redirect:/admin/groups/$id" } catch (ex: ApiException) { @@ -624,6 +634,24 @@ class AdminController( "redirect:/admin/labs/$labId" } + + private fun parseComments(json: String): List { + if (json.isBlank() || json == "[]") return emptyList() + return try { + val node = mapper.readTree(json) + buildList { + node.forEach { el -> + val text = el.path("text").asText("").trim() + if (text.isNotEmpty()) { + add(ru.ysu.lms.web.api.CommentPayload(text, el.path("tone").asText("neutral"))) + } + } + } + } catch (_: Exception) { + emptyList() + } + } + private fun token(session: HttpSession): String = session.getAttribute(SessionKeys.TOKEN) as? String ?: throw ApiException(401, "Сессия истекла, войдите заново") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 1be002f..1e553e1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -31,4 +31,6 @@ data class AdminStudentGrades( val studentNo: String, val scores: List, val average: Double?, + val comments: List>>, + val commentsJson: String, ) \ No newline at end of file diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index db20ff7..8225f78 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -306,6 +306,15 @@ class ApiClient( .retrieve().body(GradeDto::class.java) } + fun suggestComments(token: String, query: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/comment-templates?q={q}", query) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + fun myGrades(token: String): List { val type = object : ParameterizedTypeReference>() {} return call { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index dc1713a..bb4099e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -210,8 +210,12 @@ data class GradeDto( val studentId: String = "", val labId: String = "", val score: String = "", + val comments: List = emptyList(), ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class CommentDto(val text: String = "", val tone: String = "neutral") + @JsonIgnoreProperties(ignoreUnknown = true) data class SubmissionDto( val id: String = "", @@ -226,8 +230,11 @@ data class GradeSetPayload( val studentId: String, val labId: String, val score: String, + val comments: List = emptyList(), ) +data class CommentPayload(val text: String, val tone: String) + data class LabCreatePayload( val number: Int, val title: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index e166b0b..2c1386f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -84,7 +84,9 @@ class StudentController( model.addAttribute("assignment", assignment) model.addAttribute("statementHtml", assignment?.statementHtml) model.addAttribute("submissions", api.mySubmissions(t, id)) - model.addAttribute("grade", api.myGrades(t).firstOrNull { it.labId == id }?.score) + val myGrade = api.myGrades(t).firstOrNull { it.labId == id } + model.addAttribute("grade", myGrade?.score) + model.addAttribute("gradeComments", myGrade?.comments ?: emptyList()) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) model.addAttribute("canSubmit", canSubmit(lab.deadlineAt)) val defense = api.myDefense(t, id) diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 29ae525..0f46167 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -217,4 +217,17 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos .form-card { padding: 1.5rem; } /* Header logo */ -.link-inherit { font-size: 0.875rem; } \ No newline at end of file +.link-inherit { font-size: 0.875rem; }/* Chips (оценочные комментарии) */ +.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; } +.chip-pos { background: #16803c; color: #fff; } +.chip-neu { background: #5d6570; color: #fff; } +.chip-neg { background: #b3283b; color: #fff; } +/* Слоты комментариев в диалоге */ +.cs-row { display: flex; gap: 6px; align-items: center; } + +.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; } +.chip-pos { background: #16803c; color: #fff; } +.chip-neu { background: #5d6570; color: #fff; } +.chip-neg { background: #b3283b; color: #fff; } + +/* Defense timer */ diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index b34beea..047e8fd 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -46,7 +46,7 @@

Баллы за лабораторные

-

Введите балл (0–5) и нажмите «OK». Средний балл считается по оценённым лабам.

+

Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.

@@ -62,14 +62,21 @@
— -
- - - - - -
+
+
+
+ + + +
+
+
+ коммент +
+
— @@ -144,6 +151,98 @@ + + + + + + + + + + - + \ No newline at end of file diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html index 3d5e999..ea0de4d 100644 --- a/web/src/main/resources/templates/student/lab.html +++ b/web/src/main/resources/templates/student/lab.html @@ -8,6 +8,9 @@

ЛР

Оценка: + коммент Вариант