From cca20b5416e139b0aee385ed95a24580f7363bac Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Fri, 11 Sep 2026 00:03:24 +0300 Subject: [PATCH] Lab editing and group grades - academic-service: PUT /labs/{id} (title, deadline, defense, points, status, description), POST/PUT/DELETE variants endpoints - progress-service: lab_grades table, POST/GET /api/v1/grades, GET /me/grades - gateway: routes for /api/v1/assign and /api/v1/grades - web: lab edit page (metadata + per-variant forms + add/delete variant) - web: group page shows grades matrix per lab with editable score inputs and computed average column - web: student dashboard renders statement via markdown, shows deadline --- .../lms/academic/controller/LabController.kt | 24 ++++ .../kotlin/ru/ysu/lms/academic/lab/LabDtos.kt | 16 +++ .../ru/ysu/lms/academic/lab/LabService.kt | 68 +++++++++++ .../src/main/resources/application.yml | 2 +- .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 13 ++ .../ru/ysu/lms/progress/ProgressController.kt | 12 ++ .../ru/ysu/lms/progress/ProgressService.kt | 22 ++++ .../ru/ysu/lms/progress/domain/LabGrade.kt | 26 ++++ .../lms/progress/repo/LabGradeRepository.kt | 12 ++ .../resources/db/migration/V3__grades.sql | 9 ++ .../ru/ysu/lms/web/admin/AdminController.kt | 111 +++++++++++++++++- .../ru/ysu/lms/web/admin/AdminLabView.kt | 8 ++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 56 +++++++++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 32 +++++ web/src/main/resources/static/css/app.css | 1 + .../main/resources/templates/admin/group.html | 85 ++++++++++++-- .../resources/templates/admin/lab-edit.html | 81 +++++++++++++ .../main/resources/templates/admin/lab.html | 1 + 18 files changed, 567 insertions(+), 12 deletions(-) create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt create mode 100644 services/progress-service/src/main/resources/db/migration/V3__grades.sql create mode 100644 web/src/main/resources/templates/admin/lab-edit.html diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index 3cb935a..7806a6f 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile +import jakarta.validation.Valid import ru.ysu.lms.academic.lab.* import ru.ysu.lms.common.BadRequestException import java.util.UUID @@ -30,4 +31,27 @@ class LabController(private val labService: LabService) { fun delete(@PathVariable id: UUID) { labService.delete(id) } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail = + labService.update(id, body) + + @PostMapping("/{id}/variants") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail = + labService.addVariant(id, body) + + @PutMapping("/{id}/variants/{variantId}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun updateVariant( + @PathVariable id: UUID, + @PathVariable variantId: UUID, + @Valid @RequestBody body: VariantSaveRequest, + ): LabDetail = labService.updateVariant(id, variantId, body) + + @DeleteMapping("/{id}/variants/{variantId}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail = + labService.deleteVariant(id, variantId) } \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt index fb2b45b..6d00c43 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt @@ -78,4 +78,20 @@ data class VariantDetail( val number: String, val title: String, val statementMd: String, +) + +data class LabUpdateRequest( + val title: String, + val deadlineAt: String? = null, + val defenseAt: String? = null, + val pointsMax: BigDecimal, + val status: String, + val descriptionMd: String, +) + +data class VariantSaveRequest( + val labLevelId: String, + val number: String, + val title: String, + val statementMd: String, ) \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index f738f95..157dd19 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -12,6 +12,10 @@ import ru.ysu.lms.academic.repo.VariantRepository import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.BadRequestException import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID @Service @@ -135,4 +139,68 @@ class LabService( } labs.deleteById(labId) } + + @Transactional + fun update(labId: UUID, request: LabUpdateRequest): LabDetail { + val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } + if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым") + lab.title = request.title.trim() + lab.deadlineAt = parseInstant(request.deadlineAt) + lab.defenseAt = parseInstant(request.defenseAt) + lab.pointsMax = request.pointsMax + lab.status = request.status + lab.descriptionMd = request.descriptionMd + labs.save(lab) + return detail(labId) + } + + @Transactional + fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail { + val variant = variantsRepo.findById(variantId) + .orElseThrow { NotFoundException("Вариант не найден") } + if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") + if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым") + variant.number = request.number.trim() + variant.title = request.title.trim() + variant.statementMd = request.statementMd + request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId -> + variant.labLevel = labLevels.findById(UUID.fromString(levelId)) + .orElseThrow { BadRequestException("Уровень не найден") } + } + variantsRepo.save(variant) + return detail(labId) + } + + @Transactional + fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail { + val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } + if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым") + val level = labLevels.findById(UUID.fromString(request.labLevelId)) + .orElseThrow { BadRequestException("Уровень не найден") } + variantsRepo.save(Variant( + lab = lab, + labLevel = level, + number = request.number.trim(), + title = request.title.trim(), + statementMd = request.statementMd, + )) + return detail(labId) + } + + @Transactional + fun deleteVariant(labId: UUID, variantId: UUID): LabDetail { + val variant = variantsRepo.findById(variantId) + .orElseThrow { NotFoundException("Вариант не найден") } + if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") + variantsRepo.delete(variant) + return detail(labId) + } + + private fun parseInstant(value: String?): Instant? { + val v = value?.takeIf { it.isNotBlank() } ?: return null + return runCatching { Instant.parse(v) } + .recoverCatching { OffsetDateTime.parse(v).toInstant() } + .recoverCatching { LocalDateTime.parse(v).atOffset(ZoneOffset.UTC).toInstant() } + .getOrNull() + } } \ No newline at end of file diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index b068f5c..6bedd04 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/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/** + - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/** 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 38d00a4..eabf698 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 @@ -1,6 +1,7 @@ package ru.ysu.lms.progress import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import java.math.BigDecimal data class LevelAssignmentRequest( val studentId: String, @@ -120,4 +121,16 @@ data class LabStudentsResponse( data class GroupAssignmentResponse( val groupName: String, val students: List, +) + +data class GradeSetRequest( + val studentId: String, + val labId: String, + val score: BigDecimal, +) + +data class GradeResponse( + val studentId: String, + val labId: String, + val score: BigDecimal, ) \ No newline at end of file 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 f3705b5..a508e34 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 @@ -64,4 +64,16 @@ class ProgressController( @RequestParam labId: UUID, @AuthenticationPrincipal jwt: Jwt, ): List = service.assignmentsForLabEnriched(labId, jwt.tokenValue) + + @PostMapping("/grades") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body) + + @GetMapping("/grades") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun listGrades(): List = service.listGrades() + + @GetMapping("/me/grades") + fun myGrades(@AuthenticationPrincipal jwt: Jwt): List = + service.listGrades().filter { it.studentId == jwt.subject } } \ No newline at end of file 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 1466367..9923c93 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 @@ -2,17 +2,23 @@ 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.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.LabGradeRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository import ru.ysu.lms.progress.domain.StudentLabLevelKey +import java.math.BigDecimal import java.util.UUID @Service class ProgressService( private val levels: StudentLabLevelRepository, private val assignments: VariantAssignmentRepository, + private val grades: LabGradeRepository, private val academic: AcademicClient, private val identity: IdentityClient, ) { @@ -169,6 +175,22 @@ class ProgressService( fun getStudentAssignments(studentId: UUID): List = assignments.findByStudentId(studentId) + @Transactional + fun setGrade(request: GradeSetRequest): GradeResponse { + 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 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) + } + + @Transactional(readOnly = true) + fun listGrades(): List = + grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) } + @Transactional(readOnly = true) fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt new file mode 100644 index 0000000..b058927 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt @@ -0,0 +1,26 @@ +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 java.io.Serializable +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "lab_grades") +@IdClass(LabGradeKey::class) +class LabGrade( + @Id @Column(nullable = false) var studentId: UUID = UUID(0, 0), + @Id @Column(nullable = false) var labId: UUID = UUID(0, 0), + @Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO, + @Column(nullable = false) var updatedAt: Instant = Instant.now(), +) + +data class LabGradeKey( + val studentId: UUID = UUID(0, 0), + val labId: UUID = UUID(0, 0), +) : Serializable \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt new file mode 100644 index 0000000..d4da110 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt @@ -0,0 +1,12 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.LabGrade +import ru.ysu.lms.progress.domain.LabGradeKey +import java.util.UUID + +interface LabGradeRepository : JpaRepository { + fun findAllBy(): List + fun findByStudentId(studentId: UUID): List + fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade? +} diff --git a/services/progress-service/src/main/resources/db/migration/V3__grades.sql b/services/progress-service/src/main/resources/db/migration/V3__grades.sql new file mode 100644 index 0000000..f6a1097 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V3__grades.sql @@ -0,0 +1,9 @@ +-- Lab grades: one grade per student per lab. + +CREATE TABLE lab_grades ( + student_id uuid NOT NULL, + lab_id uuid NOT NULL, + score numeric(4,1) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (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 18c4617..9bf6002 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 @@ -16,7 +16,10 @@ 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.AssignVariantPayload +import ru.ysu.lms.web.api.GradeSetPayload import ru.ysu.lms.web.api.LabImportResultDto +import ru.ysu.lms.web.api.LabUpdatePayload +import ru.ysu.lms.web.api.VariantSavePayload import ru.ysu.lms.web.api.DistributeResultDto import ru.ysu.lms.web.api.LevelAssignPayload import ru.ysu.lms.web.api.LevelAssignmentResultDto @@ -90,10 +93,45 @@ class AdminController(private val api: ApiClient) { session: HttpSession, model: Model, ): String { - model.addAttribute("group", api.groupDetail(token(session), id)) + val t = token(session) + val group = api.groupDetail(t, id) + val labs = api.listLabs(t) + val grades = api.listGrades(t) + val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } + val studentGrades = group.students.map { s -> + val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] } + val nums = scores.mapNotNull { it?.toBigDecimalOrNull() } + AdminStudentGrades( + studentId = s.id, + fullName = s.fullName, + studentNo = s.studentNo, + scores = scores, + average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(), + ) + } + model.addAttribute("group", group) + model.addAttribute("labs", labs) + model.addAttribute("studentGrades", studentGrades) return "admin/group" } + @PostMapping("/groups/{id}/grades") + fun setGrade( + @PathVariable id: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam score: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.setGrade(token(session), GradeSetPayload(studentId, labId, score)) + redirect.addFlashAttribute("notice", "Оценка сохранена") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + @PostMapping("/groups/{id}/students/{studentId}/reset-password") fun resetPassword( @PathVariable id: String, @@ -228,6 +266,77 @@ class AdminController(private val api: ApiClient) { "redirect:/admin/labs/$labId" } + @GetMapping("/labs/{id}/edit") + fun editLabForm( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val lab = api.labDetail(token(session), id) + // datetime-local требует формат yyyy-MM-dd'T'HH:mm + model.addAttribute("lab", lab) + model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "") + model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "") + return "admin/lab-edit" + } + + @PostMapping("/labs/{id}/edit") + fun editLab( + @PathVariable id: String, + @RequestParam title: String, + @RequestParam(required = false) deadlineAt: String?, + @RequestParam(required = false) defenseAt: String?, + @RequestParam pointsMax: String, + @RequestParam status: String, + @RequestParam descriptionMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.updateLab( + token(session), id, + LabUpdatePayload(title, deadlineAt?.takeIf { it.isNotBlank() }, defenseAt?.takeIf { it.isNotBlank() }, pointsMax, status, descriptionMd), + ) + redirect.addFlashAttribute("notice", "Лабораторная сохранена") + "redirect:/admin/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id/edit" + } + + @PostMapping("/labs/{labId}/variants/save") + fun saveVariant( + @PathVariable labId: String, + @RequestParam(required = false) variantId: String?, + @RequestParam labLevelId: String, + @RequestParam number: String, + @RequestParam title: String, + @RequestParam statementMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.saveVariant(token(session), labId, variantId, VariantSavePayload(labLevelId, number, title, statementMd)) + redirect.addFlashAttribute("notice", "Вариант сохранён") + "redirect:/admin/labs/$labId/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId/edit" + } + + @PostMapping("/labs/{labId}/variants/{variantId}/delete") + fun deleteVariant( + @PathVariable labId: String, + @PathVariable variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.deleteVariant(token(session), labId, variantId) + redirect.addFlashAttribute("notice", "Вариант удалён") + "redirect:/admin/labs/$labId/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId/edit" + } + @PostMapping("/labs/{id}/distribute") fun distribute( @PathVariable id: String, 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 90c2f53..e1175db 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 @@ -15,4 +15,12 @@ data class AdminLabStudent( val variantId: String?, val variantNumber: String?, val source: String, +) + +data class AdminStudentGrades( + val studentId: String, + val fullName: String, + val studentNo: String, + val scores: List, + val average: Double?, ) \ 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 b75343a..4cdc0b8 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 @@ -235,6 +235,62 @@ class ApiClient( } ?: emptyList() } + fun updateLab(token: String, labId: String, payload: LabUpdatePayload): LabDetailDto = + call { + client.put().uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(LabDetailDto::class.java) + } + + fun saveVariant(token: String, labId: String, variantId: String?, payload: VariantSavePayload): LabDetailDto = + if (variantId.isNullOrBlank()) { + addVariant(token, labId, payload) + } else { + call { + client.put().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(LabDetailDto::class.java) + } + } + + fun addVariant(token: String, labId: String, payload: VariantSavePayload): LabDetailDto = + call { + client.post().uri("/api/v1/labs/{id}/variants", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(LabDetailDto::class.java) + } + + fun deleteVariant(token: String, labId: String, variantId: String): LabDetailDto = + call { + client.delete().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(LabDetailDto::class.java) + } + + fun listGrades(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/grades") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun setGrade(token: String, payload: GradeSetPayload): GradeDto = + call { + client.post().uri("/api/v1/grades") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(GradeDto::class.java) + } + private companion object { val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") } 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 cb8b9ee..9041bea 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 @@ -162,6 +162,9 @@ data class LabDetailDto( val title: String = "", val status: String = "", val descriptionMd: String = "", + val deadlineAt: String? = null, + val defenseAt: String? = null, + val pointsMax: Any? = null, val levels: List = emptyList(), ) @@ -181,6 +184,35 @@ data class VariantDetailDto( val statementMd: String = "", ) +data class LabUpdatePayload( + val title: String, + val deadlineAt: String?, + val defenseAt: String?, + val pointsMax: String, + val status: String, + val descriptionMd: String, +) + +data class VariantSavePayload( + val labLevelId: String, + val number: String, + val title: String, + val statementMd: String, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GradeDto( + val studentId: String = "", + val labId: String = "", + val score: String = "", +) + +data class GradeSetPayload( + val studentId: String, + val labId: String, + val score: String, +) + class DownloadDto( val bytes: ByteArray, val filename: String, diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index bd9cbaa..6562372 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -163,6 +163,7 @@ tr:last-child td { border-bottom: none; } .inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; } .inline-form .input-sm { width: auto; min-width: 10rem; } .input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; } +.input-score { width: 4.5rem; } .filter-select { width: auto; } .hidden { display: none !important; } diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index e9f4d7b..23090d4 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -3,36 +3,101 @@ Группа
- ← Все группы + ← Все группы
-

Группа

Студентов: 0

- +
+

Группа

+

Студентов: 0

+
+
+
-
Новый пароль: password
+
+ Новый пароль: password +
+

Импорт: группа

-

Новых студентов: 0, обновлено: 0.

+

+ Новых студентов: 0, + обновлено: 0. +

Сохраните пароли — они показываются для выдачи студентам.

- - +
ФИОЛогинПароль
———
+ + + + + + + +
ФИОЛогинПароль
———
+ +

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

+

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

+
+ + + + + + + + + + + + + + + + + + +
Студент + ЛР + Средний
— +
+ + + + +
+
+ — + — +
В группе нет студентов.
+
+ +

Студенты

- +
№ФИОЛогинОсноваПароль
+ - - + + diff --git a/web/src/main/resources/templates/admin/lab-edit.html b/web/src/main/resources/templates/admin/lab-edit.html new file mode 100644 index 0000000..96e28d4 --- /dev/null +++ b/web/src/main/resources/templates/admin/lab-edit.html @@ -0,0 +1,81 @@ + + +Правка лабы + +
+ ← К лабораторной +

Правка лабораторной

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

Варианты

+
+

Уровень

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

Добавить вариант

+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 1a01099..fe984c1 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -10,6 +10,7 @@

Статус: —

+ Редактировать
№ФИОЛогинОсноваПароль
1 — — —временныйизменён
временный + изменён +
+ +
+
В группе пока нет студентов.