From f7a18305486b13e36730471a87af0a1906d229b4 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 26 Oct 2026 16:33:59 +0300 Subject: [PATCH] feat: add course attestations and average grade column - store attestation columns and per-student results in progress-service - add teacher UI for intermediate/pass/exam columns with semester and missed hours - show average score per student on the group page - show attestation results on the student dashboard --- .../src/main/resources/application.yml | 2 +- .../ysu/lms/progress/AttestationController.kt | 58 ++++++++ .../ru/ysu/lms/progress/AttestationService.kt | 134 ++++++++++++++++++ .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 28 ++++ .../ysu/lms/progress/StudentPurgeService.kt | 3 + .../ru/ysu/lms/progress/domain/Attestation.kt | 41 ++++++ .../progress/repo/AttestationRepository.kt | 19 +++ .../db/migration/V11__attestations.sql | 24 ++++ .../ru/ysu/lms/web/AttestationPresentation.kt | 55 +++++++ .../web/admin/AdminAttestationController.kt | 96 +++++++++++++ .../kotlin/ru/ysu/lms/web/admin/AdminForms.kt | 12 ++ .../ysu/lms/web/admin/AdminGroupController.kt | 73 +++++++--- .../ru/ysu/lms/web/admin/AdminLabView.kt | 18 +++ .../lms/web/admin/GroupAttestationViews.kt | 49 +++++++ .../ysu/lms/web/api/ApiClientAttestation.kt | 80 +++++++++++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 28 ++++ .../ru/ysu/lms/web/form/FormFeedback.kt | 5 +- .../ysu/lms/web/student/StudentController.kt | 30 ++++ web/src/main/resources/static/css/app.css | 5 + .../main/resources/static/js/admin-group.js | 53 +++++++ web/src/main/resources/static/js/app.js | 1 + .../main/resources/templates/admin/group.html | 97 ++++++++++++- .../resources/templates/fragments/base.html | 4 +- .../templates/student/dashboard.html | 24 ++++ 24 files changed, 914 insertions(+), 25 deletions(-) create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/AttestationRepository.kt create mode 100644 services/progress-service/src/main/resources/db/migration/V11__attestations.sql create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/AttestationPresentation.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index c9c3120..f72529d 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/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/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/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**,/api/v1/attestations,/api/v1/attestations/** management: endpoints: diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt new file mode 100644 index 0000000..dfecdf4 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt @@ -0,0 +1,58 @@ +package ru.ysu.lms.progress + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1") +class AttestationController( + private val service: AttestationService, +) { + @GetMapping("/attestations") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun list( + @RequestParam groupId: UUID, + ): List = service.list(groupId) + + @PostMapping("/attestations") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun create( + @RequestBody body: AttestationCreateRequest, + ): AttestationResponse = service.create(body) + + @DeleteMapping("/attestations/{id}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun delete( + @PathVariable id: UUID, + ): Map { + service.delete(id) + return mapOf("ok" to true) + } + + @PostMapping("/attestations/{id}/results") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun setResult( + @PathVariable id: UUID, + @RequestBody body: AttestationResultRequest, + ): AttestationResponse = service.setResult(id, body) + + /** Результаты аттестаций текущего студента по его группе. */ + @GetMapping("/me/attestations") + fun myAttestations( + @AuthenticationPrincipal jwt: Jwt, + @RequestParam(required = false) groupId: UUID?, + ): List { + if (groupId == null) return emptyList() + return service.listForStudent(groupId, UUID.fromString(jwt.subject)) + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt new file mode 100644 index 0000000..7802584 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt @@ -0,0 +1,134 @@ +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.common.NotFoundException +import ru.ysu.lms.progress.domain.Attestation +import ru.ysu.lms.progress.domain.AttestationResult +import ru.ysu.lms.progress.domain.AttestationResultKey +import ru.ysu.lms.progress.repo.AttestationRepository +import ru.ysu.lms.progress.repo.AttestationResultRepository +import java.time.Instant +import java.util.UUID + +@Service +class AttestationService( + private val attestations: AttestationRepository, + private val results: AttestationResultRepository, +) { + @Transactional(readOnly = true) + fun list(groupId: UUID): List { + val columns = attestations.findByGroupIdOrderByCreatedAtAscIdAsc(groupId) + if (columns.isEmpty()) return emptyList() + val byAttestation = results.findByAttestationIdIn(columns.map { it.id }).groupBy { it.attestationId } + return columns.map { toResponse(it, byAttestation[it.id].orEmpty()) } + } + + @Transactional(readOnly = true) + fun listForStudent( + groupId: UUID, + studentId: UUID, + ): List = + list(groupId).map { column -> + column.copy(results = column.results.filter { it.studentId == studentId.toString() }) + } + + @Transactional + fun create(request: AttestationCreateRequest): AttestationResponse { + val kind = normalizeKind(request.kind) + val semester = request.semester.trim() + if (semester.isEmpty()) throw BadRequestException("Укажите семестр") + if (semester.length > MAX_SEMESTER_LENGTH) throw BadRequestException("Слишком длинное название семестра") + val column = + attestations.save( + Attestation( + groupId = UUID.fromString(request.groupId), + kind = kind, + semester = semester, + ), + ) + return toResponse(column, emptyList()) + } + + @Transactional + fun delete(attestationId: UUID) { + val column = + attestations + .findById(attestationId) + .orElseThrow { NotFoundException("Аттестация не найдена") } + attestations.delete(column) + } + + @Transactional + fun setResult( + attestationId: UUID, + request: AttestationResultRequest, + ): AttestationResponse { + val column = + attestations + .findById(attestationId) + .orElseThrow { NotFoundException("Аттестация не найдена") } + val studentId = UUID.fromString(request.studentId) + val key = AttestationResultKey(attestationId, studentId) + val value = request.value.trim().lowercase() + val existing = results.findById(key).orElse(null) + + if (value.isEmpty()) { + existing?.let { results.delete(it) } + return toResponse(column, results.findByAttestationId(attestationId)) + } + + val allowed = VALUES[column.kind] ?: emptySet() + if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации") + val missedHours = if (column.kind == KIND_INTERMEDIATE) request.missedHours else null + if (missedHours != null && missedHours < 0) { + throw BadRequestException("Число пропущенных часов не может быть отрицательным") + } + + val entity = + (existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply { + this.value = value + this.missedHours = missedHours + this.updatedAt = Instant.now() + } + results.save(entity) + return toResponse(column, results.findByAttestationId(attestationId)) + } + + private fun normalizeKind(raw: String): String { + val kind = raw.trim().lowercase() + if (kind !in KINDS) throw BadRequestException("Неизвестный тип аттестации") + return kind + } + + private fun toResponse( + column: Attestation, + rows: List, + ): AttestationResponse = + AttestationResponse( + id = column.id.toString(), + groupId = column.groupId.toString(), + kind = column.kind, + semester = column.semester, + results = + rows + .sortedBy { it.studentId } + .map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) }, + ) + + companion object { + const val KIND_INTERMEDIATE = "intermediate" + const val KIND_PASS = "pass" + const val KIND_EXAM = "exam" + private const val MAX_SEMESTER_LENGTH = 100 + + private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM) + private val VALUES = + mapOf( + KIND_INTERMEDIATE to setOf("excellent", "satisfactory", "unsatisfactory", "not_attested"), + KIND_PASS to setOf("passed", "failed", "absent"), + KIND_EXAM to setOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"), + ) + } +} 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 5a72900..f4d4953 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 @@ -163,6 +163,34 @@ data class GradeResponse( val generalComment: String = "", ) +data class AttestationCreateRequest( + val groupId: String, + /** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */ + val kind: String, + val semester: String, +) + +data class AttestationResultRequest( + val studentId: String, + /** Пустое значение очищает результат. */ + val value: String = "", + val missedHours: Int? = null, +) + +data class AttestationResultResponse( + val studentId: String, + val value: String, + val missedHours: Int? = null, +) + +data class AttestationResponse( + val id: String, + val groupId: String, + val kind: String, + val semester: String, + val results: List = emptyList(), +) + data class SubmissionDto( val id: String, val seq: Int, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt index efc0913..14b41cb 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt @@ -2,6 +2,7 @@ package ru.ysu.lms.progress import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.progress.repo.AttestationResultRepository import ru.ysu.lms.progress.repo.DefenseSessionRepository import ru.ysu.lms.progress.repo.GradeCommentRepository import ru.ysu.lms.progress.repo.GradePublicationStudentRepository @@ -21,6 +22,7 @@ class StudentPurgeService( private val assignments: VariantAssignmentRepository, private val levels: StudentLabLevelRepository, private val publicationsSt: GradePublicationStudentRepository, + private val attestationResults: AttestationResultRepository, private val storage: SubmissionStorage, ) { /** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */ @@ -33,6 +35,7 @@ class StudentPurgeService( assignments.findByStudentId(studentId).also { assignments.deleteAll(it) } levels.findByStudentId(studentId).also { levels.deleteAll(it) } publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) } + attestationResults.findByStudentId(studentId).also { attestationResults.deleteAll(it) } } fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt new file mode 100644 index 0000000..0e39311 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt @@ -0,0 +1,41 @@ +package ru.ysu.lms.progress.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.IdClass +import jakarta.persistence.Table +import ru.ysu.lms.common.Ids +import java.io.Serializable +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "attestations") +class Attestation( + @Id var id: UUID = Ids.v7(), + @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0), + @Column(nullable = false, length = 16) var kind: String = "intermediate", + @Column(nullable = false, length = 100) var semester: String = "", + @Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(), +) + +@Entity +@Table(name = "attestation_results") +@IdClass(AttestationResultKey::class) +class AttestationResult( + @Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0), + @Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0), + @Column(nullable = false, length = 32) var value: String = "", + @Column(name = "missed_hours") var missedHours: Int? = null, + @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), +) + +data class AttestationResultKey( + val attestationId: UUID = UUID(0, 0), + val studentId: UUID = UUID(0, 0), +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/AttestationRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/AttestationRepository.kt new file mode 100644 index 0000000..4927cdf --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/AttestationRepository.kt @@ -0,0 +1,19 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.Attestation +import ru.ysu.lms.progress.domain.AttestationResult +import ru.ysu.lms.progress.domain.AttestationResultKey +import java.util.UUID + +interface AttestationRepository : JpaRepository { + fun findByGroupIdOrderByCreatedAtAscIdAsc(groupId: UUID): List +} + +interface AttestationResultRepository : JpaRepository { + fun findByAttestationId(attestationId: UUID): List + + fun findByAttestationIdIn(attestationIds: Collection): List + + fun findByStudentId(studentId: UUID): List +} diff --git a/services/progress-service/src/main/resources/db/migration/V11__attestations.sql b/services/progress-service/src/main/resources/db/migration/V11__attestations.sql new file mode 100644 index 0000000..c70ed80 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V11__attestations.sql @@ -0,0 +1,24 @@ +-- Аттестации группы: промежуточная аттестация, зачёт, экзамен. +CREATE TABLE attestations ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + group_id uuid NOT NULL, + kind varchar(16) NOT NULL, + semester varchar(100) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT attestations_kind_check CHECK (kind IN ('intermediate', 'pass', 'exam')) +); + +CREATE INDEX attestations_group_idx ON attestations (group_id, created_at); + +CREATE TABLE attestation_results ( + attestation_id uuid NOT NULL REFERENCES attestations (id) ON DELETE CASCADE, + student_id uuid NOT NULL, + value varchar(32) NOT NULL, + missed_hours integer, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (attestation_id, student_id), + CONSTRAINT attestation_results_value_check CHECK (value <> ''), + CONSTRAINT attestation_results_hours_check CHECK (missed_hours IS NULL OR missed_hours >= 0) +); + +CREATE INDEX attestation_results_student_idx ON attestation_results (student_id); diff --git a/web/src/main/kotlin/ru/ysu/lms/web/AttestationPresentation.kt b/web/src/main/kotlin/ru/ysu/lms/web/AttestationPresentation.kt new file mode 100644 index 0000000..d362007 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/AttestationPresentation.kt @@ -0,0 +1,55 @@ +package ru.ysu.lms.web + +/** Подписи типов и оценок аттестации для интерфейса. */ +object AttestationPresentation { + private val KIND_LABELS = + mapOf( + "intermediate" to "Промежуточная аттестация", + "pass" to "Зачёт", + "exam" to "Экзамен", + ) + + private val VALUE_LABELS = + mapOf( + "intermediate" to + mapOf( + "excellent" to "отл", + "satisfactory" to "атт", + "unsatisfactory" to "неуд", + "not_attested" to "не атт", + ), + "pass" to + mapOf( + "passed" to "зачтено", + "failed" to "не зачтено", + "absent" to "неявка", + ), + "exam" to + mapOf( + "excellent" to "отлично", + "good" to "хорошо", + "satisfactory" to "удовлетворительно", + "unsatisfactory" to "неудовлетворительно", + "absent" to "неявка", + ), + ) + + private val POSITIVE_VALUES = + mapOf( + "intermediate" to setOf("excellent", "satisfactory"), + "pass" to setOf("passed"), + "exam" to setOf("excellent", "good", "satisfactory"), + ) + + fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind + + fun valueLabel( + kind: String, + value: String, + ): String = VALUE_LABELS[kind]?.get(value) ?: value + + fun isPositive( + kind: String, + value: String, + ): Boolean? = if (value.isBlank()) null else POSITIVE_VALUES[kind]?.contains(value) == true +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt new file mode 100644 index 0000000..e16d8fe --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt @@ -0,0 +1,96 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.AttestationCreatePayload +import ru.ysu.lms.web.api.AttestationResultPayload +import ru.ysu.lms.web.api.createAttestation +import ru.ysu.lms.web.api.deleteAttestation +import ru.ysu.lms.web.api.setAttestationResult +import ru.ysu.lms.web.session.SessionKeys + +@Controller +@RequestMapping("/admin") +class AdminAttestationController( + private val api: ApiClient, +) { + @PostMapping("/groups/{id}/attestations") + fun createAttestation( + @PathVariable id: String, + @ModelAttribute form: CreateAttestationForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val semester = form.semester.trim() + if (semester.isBlank()) throw ApiException(400, "Укажите семестр") + api.createAttestation( + token(session), + AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester), + ) + redirect.addFlashAttribute("notice", "Столбец аттестации добавлен") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + @PostMapping("/groups/{id}/attestations/{attestationId}/delete") + fun deleteAttestation( + @PathVariable id: String, + @PathVariable attestationId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.deleteAttestation(token(session), attestationId) + redirect.addFlashAttribute("notice", "Столбец аттестации удалён") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + @PostMapping("/groups/{id}/attestation-results") + fun setAttestationResult( + @PathVariable id: String, + @ModelAttribute form: SetAttestationResultForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.setAttestationResult( + token(session), + form.attestationId, + AttestationResultPayload( + studentId = form.studentId, + value = form.value, + missedHours = parseMissedHours(form.missedHours), + ), + ) + redirect.addFlashAttribute("notice", "Результат аттестации сохранён") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + private fun parseMissedHours(raw: String?): Int? { + val text = raw?.trim().orEmpty() + if (text.isEmpty()) return null + val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым") + if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным") + return value + } + + 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/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt index 9d23cbb..2060425 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt @@ -85,6 +85,18 @@ data class SetGradeForm( val generalComment: String = "", ) +data class CreateAttestationForm( + val kind: String = "", + val semester: String = "", +) + +data class SetAttestationResultForm( + val attestationId: String = "", + val studentId: String = "", + val value: String = "", + val missedHours: String? = null, +) + data class SubmissionDownloadForm( val group: String? = null, val no: String? = null, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index db606cd..f186a45 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -38,6 +38,7 @@ import ru.ysu.lms.web.api.groupLabData import ru.ysu.lms.web.api.importGroup import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listAttestations import ru.ysu.lms.web.api.listGrades import ru.ysu.lms.web.api.listGroups import ru.ysu.lms.web.api.listLabs @@ -46,6 +47,8 @@ import ru.ysu.lms.web.api.setGrade import ru.ysu.lms.web.api.setPublication import ru.ysu.lms.web.api.suggestComments import tools.jackson.databind.ObjectMapper +import java.math.BigDecimal +import java.math.RoundingMode import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -137,7 +140,11 @@ class AdminGroupController( model: Model, ): String { val t = token(session) - val (group, labs) = loadGroupAndLabs(t, id) + val allLabs = api.listLabs(t) + val group = api.groupDetail(t, id) + val labs = allLabs.filter { it.status == "published" } + val attestations = GroupAttestationViews.columns(api.listAttestations(t, id)) + val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations) val grades = api.listGrades(t) val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } @@ -166,19 +173,29 @@ class AdminGroupController( val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } val rows = - buildRows( - group.students, - labs, - gradeBy, - generalCommentBy, - commentsBy, - publishedByLab, - assignmentsBy, - submissionsBy, - defensesBy, + GroupAttestationViews.attach( + buildRows( + group.students, + labs, + gradeBy, + generalCommentBy, + commentsBy, + publishedByLab, + assignmentsBy, + submissionsBy, + defensesBy, + ), + attestations, ) - fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy))) + fillGroupModel( + model, + groupViewModel( + t, + id, + GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters), + ), + ) return "admin/group" } @@ -222,6 +239,7 @@ class AdminGroupController( defenseAssignmentMd = def?.assignmentMd, ) } + val averageScore = averageScore(cells) GroupStudentRow( studentId = s.id, fullName = s.fullName, @@ -239,13 +257,19 @@ class AdminGroupController( cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } }, ), + averageScore = averageScore, ) } - private fun loadGroupAndLabs( - token: String, - id: String, - ): Pair> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" } + private fun averageScore(cells: List): String? { + val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() } + if (scores.isEmpty()) return null + return scores + .fold(BigDecimal.ZERO, BigDecimal::add) + .divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() + } private data class GroupRenderData( val group: GroupDetailDto, @@ -253,6 +277,8 @@ class AdminGroupController( val rows: List, val labPublication: Map, val submissionsBy: Map>, + val attestations: List, + val attestationSemesters: List, ) private fun groupViewModel( @@ -269,7 +295,16 @@ class AdminGroupController( lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0) } val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) - return GroupViewModel(data.group, data.labs, candidates, data.rows, data.labPublication, submissionCounts) + return GroupViewModel( + data.group, + data.labs, + candidates, + data.rows, + data.labPublication, + submissionCounts, + data.attestations, + data.attestationSemesters, + ) } private data class GroupViewModel( @@ -279,6 +314,8 @@ class AdminGroupController( val rows: List, val labPublication: Map, val submissionCounts: Map, + val attestations: List, + val attestationSemesters: List, ) private fun fillGroupModel( @@ -291,6 +328,8 @@ class AdminGroupController( model.addAttribute("studentGrades", view.rows) model.addAttribute("labPublication", view.labPublication) model.addAttribute("labSubmissionCounts", view.submissionCounts) + model.addAttribute("attestations", view.attestations) + model.addAttribute("attestationSemesters", view.attestationSemesters) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) } 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 7135c52..3e6b8a3 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 @@ -49,6 +49,24 @@ data class GroupStudentRow( val cells: List, val variantsSummary: String, val commentsJson: String, + val averageScore: String? = null, + val attestations: Map = emptyMap(), +) + +/** Результат студента по одному столбцу аттестации. */ +data class GroupAttestationResult( + val value: String = "", + val label: String = "", + val missedHours: Int? = null, +) + +/** Столбец аттестации на странице группы. */ +data class GroupAttestation( + val id: String = "", + val kind: String = "", + val kindLabel: String = "", + val semester: String = "", + val results: Map = emptyMap(), ) /** Ряд блока защит (одна сессия на строку). */ diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt new file mode 100644 index 0000000..fd3df90 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt @@ -0,0 +1,49 @@ +package ru.ysu.lms.web.admin + +import ru.ysu.lms.web.AttestationPresentation +import ru.ysu.lms.web.api.AttestationDto +import ru.ysu.lms.web.api.LabSummaryDto + +/** Преобразование ответов сервиса аттестаций в модель страницы группы. */ +object GroupAttestationViews { + fun columns(attestations: List): List = + attestations.map { column -> + GroupAttestation( + id = column.id, + kind = column.kind, + kindLabel = AttestationPresentation.kindLabel(column.kind), + semester = column.semester, + results = + column.results.associate { row -> + row.studentId to + GroupAttestationResult( + value = row.value, + label = AttestationPresentation.valueLabel(column.kind, row.value), + missedHours = row.missedHours, + ) + }, + ) + } + + fun semesters( + labs: List, + attestations: List, + ): List = + (labs.map { it.semesterTitle } + attestations.map { it.semester }) + .filter { it.isNotBlank() && it != "—" } + .distinct() + .sorted() + + fun attach( + rows: List, + columns: List, + ): List = + rows.map { row -> + val byColumn = LinkedHashMap() + for (column in columns) { + val result = column.results[row.studentId] ?: continue + byColumn[column.id] = result + } + row.copy(attestations = byColumn) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt new file mode 100644 index 0000000..0b31578 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt @@ -0,0 +1,80 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType + +fun ApiClient.listAttestations( + token: String, + groupId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/attestations?groupId={gid}", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.createAttestation( + token: String, + payload: AttestationCreatePayload, +): AttestationDto = + call { + client + .post() + .uri("/api/v1/attestations") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(AttestationDto::class.java) + } + +fun ApiClient.deleteAttestation( + token: String, + attestationId: String, +) { + call { + client + .delete() + .uri("/api/v1/attestations/{id}", attestationId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.setAttestationResult( + token: String, + attestationId: String, + payload: AttestationResultPayload, +): AttestationDto = + call { + client + .post() + .uri("/api/v1/attestations/{id}/results", attestationId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(AttestationDto::class.java) + } + +fun ApiClient.myAttestations( + token: String, + groupId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/me/attestations?groupId={gid}", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} 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 1aa4c7a..4c1c46a 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 @@ -264,6 +264,34 @@ data class GradeDto( val generalComment: String = "", ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class AttestationResultDto( + val studentId: String = "", + val value: String = "", + val missedHours: Int? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AttestationDto( + val id: String = "", + val groupId: String = "", + val kind: String = "", + val semester: String = "", + val results: List = emptyList(), +) + +data class AttestationCreatePayload( + val groupId: String, + val kind: String, + val semester: String, +) + +data class AttestationResultPayload( + val studentId: String, + val value: String, + val missedHours: Int? = null, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class CommentDto( val text: String = "", diff --git a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt index a21efe2..ed7c14e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt @@ -54,7 +54,10 @@ data class FormFeedback( Rule(Regex("(?iu)номер лабораторной"), listOf("number")), Rule(Regex("(?iu)номер варианта"), listOf("number")), Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")), - Rule(Regex("(?iu)оценк"), listOf("score")), + Rule(Regex("(?iu)оценк"), listOf("score", "value")), + Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")), + Rule(Regex("(?iu)семестр"), listOf("semester")), + Rule(Regex("(?iu)тип аттестации"), listOf("kind")), Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")), Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")), Rule( 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 ded0956..7a88fd0 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 @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.multipart.MultipartFile import org.springframework.web.servlet.mvc.support.RedirectAttributes import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.AttestationPresentation import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException @@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.finishDefense import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.meAssignments +import ru.ysu.lms.web.api.myAttestations import ru.ysu.lms.web.api.myDefense import ru.ysu.lms.web.api.myDefenseStatus import ru.ysu.lms.web.api.myGrades @@ -59,6 +61,14 @@ class StudentController( val submissionCount: Int, ) + data class StudentAttestationView( + val kindLabel: String, + val semester: String, + val valueLabel: String, + val positive: Boolean?, + val missedHours: Int?, + ) + @GetMapping fun dashboard( session: HttpSession, @@ -72,6 +82,26 @@ class StudentController( model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat()) model.addAttribute("best", scored.maxOrNull()) model.addAttribute("worst", scored.minOrNull()) + val groupId = studentGroupId(t) + val attestations = + if (groupId == null) { + emptyList() + } else { + runCatching { api.myAttestations(t, groupId) }.getOrDefault(emptyList()) + } + model.addAttribute( + "attestations", + attestations.map { column -> + val result = column.results.firstOrNull() + StudentAttestationView( + kindLabel = AttestationPresentation.kindLabel(column.kind), + semester = column.semester, + valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—", + positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) }, + missedHours = result?.missedHours, + ) + }, + ) return "student/dashboard" } diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 53fef0a..fb02ac0 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -557,6 +557,11 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; } .matrix tbody tr:last-child td { border-bottom: none; } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } +.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); } +.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); } +.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); font-weight: 600; } +.attestation-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; text-align: left; } +.attestation-kind { font-weight: 600; color: var(--c-text); } .matrix td.student-cell { min-width: 12rem; } .student-name { font-weight: 600; font-size: 0.84375rem; } .student-cell .font-mono { margin-top: 0.125rem; } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 3cc06c0..77d2036 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -32,6 +32,59 @@ function openGradeEditor(btn) { if (d && d.showModal) d.showModal(); } +/* ---------- Аттестация: выбор результата ---------- */ + +var ATTESTATION_OPTIONS = { + intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']], + pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']], + exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']] +}; + +function openAttestation(btn) { + var form = $id('attestation-form'); + if (!form) return; + + var kind = btn.getAttribute('data-attestation-kind') || 'intermediate'; + var student = btn.getAttribute('data-student') || ''; + var studentName = btn.getAttribute('data-student-name') || ''; + var label = btn.getAttribute('data-attestation-label') || 'Аттестация'; + var semester = btn.getAttribute('data-semester') || ''; + var value = btn.getAttribute('data-attestation-value') || ''; + + $id('attestation-id').value = btn.getAttribute('data-attestation') || ''; + $id('attestation-student').value = student; + $id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : ''); + + var select = $id('attestation-value'); + select.innerHTML = ''; + var empty = document.createElement('option'); + empty.value = ''; + empty.textContent = '— не выставлено —'; + select.appendChild(empty); + (ATTESTATION_OPTIONS[kind] || []).forEach(function (item) { + var option = document.createElement('option'); + option.value = item[0]; + option.textContent = item[1]; + select.appendChild(option); + }); + select.value = value; + + var hours = $id('attestation-hours'); + var hoursWrap = $id('attestation-hours-wrap'); + var missed = btn.getAttribute('data-missed-hours'); + if (hours) hours.value = missed && missed !== 'null' ? missed : ''; + if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate'; + if (kind !== 'intermediate' && hours) hours.value = ''; + + var title = $id('attestation-title'); + if (title) title.textContent = label + ' — ' + studentName; + + var dialog = $id('modal-attestation'); + if (dialog && dialog.showModal) dialog.showModal(); +} + +window.openAttestation = openAttestation; + function csRow(text, tone) { var box = $id('cs-rows'); var row = document.createElement('div'); diff --git a/web/src/main/resources/static/js/app.js b/web/src/main/resources/static/js/app.js index abd376c..b1b73ea 100644 --- a/web/src/main/resources/static/js/app.js +++ b/web/src/main/resources/static/js/app.js @@ -270,6 +270,7 @@ var kind = action.getAttribute('data-action'); var fn = { grade: window.openGradeEditor, + attestation: window.openAttestation, variant: window.openVariant, defense: window.openDefense, assign: window.openLabAssign, diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 8584b2b..1fd44a7 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -17,6 +17,7 @@
Скачать пароли +
@@ -30,10 +31,10 @@ -
Опубликованных лабораторных пока нет.
+
Опубликованных лабораторных пока нет.
-
+
@@ -70,6 +71,23 @@ + + @@ -120,6 +138,19 @@ + + - +
Средний балл +
+
+
+ + +
+
+ Аттестация + — +
+
Пароль Действия
— + + временный изменён @@ -139,7 +170,7 @@
В группе нет студентов.
В группе нет студентов.
@@ -173,6 +204,64 @@
+ + +
+

Новая аттестация

+ +
+
+
+ +
+
+
+ + + + +

Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.

+
+ +
+
+
+ + + +
+

Результат аттестации

+ +
+
+
+ + + +
+
+
+ +

Оставьте пустым, чтобы убрать результат.

+
+
+ +

Укажите для оценки «не атт», если известны пропуски по любой причине.

+
+ +
+
+
+ @@ -347,7 +436,7 @@
- +
diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 288d188..2a6dec5 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -17,7 +17,7 @@ - + @@ -25,7 +25,7 @@ - + diff --git a/web/src/main/resources/templates/student/dashboard.html b/web/src/main/resources/templates/student/dashboard.html index 4e25e58..02749f1 100644 --- a/web/src/main/resources/templates/student/dashboard.html +++ b/web/src/main/resources/templates/student/dashboard.html @@ -24,6 +24,30 @@ Учебные материалы +

Аттестации

+
+ + + + + + + + + + + + +
Форма аттестацииСеместрРезультатПропущено
—— + — + —
+
+
+ Аттестаций пока нет. Преподаватель добавит результаты после их выставления. +
+

Мои лабораторные