From 0fdb9faab620d8741c85fa2236dc7b0c73e3a6a0 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 10 Sep 2026 22:18:47 +0300 Subject: [PATCH] Complete distribution flow and student UI - progress-service: Distribute variants (round-robin per lab per level), student level assignment, API for levels/assignments - progress-service: Inter-service clients (academic, identity) via RestClient - web: Admin lab detail page with distribute/level buttons - web: Student dashboard shows assigned labs from progress-service - web: ApiClient methods for labs detail, distribute, levels, me/assignments - academic-service: Add id to LabLevelDetail DTO for progress-service - Fix: CSS-only layout, footer at bottom via flex column --- .../kotlin/ru/ysu/lms/academic/lab/LabDtos.kt | 1 + .../ru/ysu/lms/academic/lab/LabService.kt | 1 + .../ru/ysu/lms/progress/AcademicClient.kt | 46 ++++++ .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 84 ++++++++++ .../ru/ysu/lms/progress/ProgressController.kt | 50 ++++++ .../ru/ysu/lms/progress/ProgressService.kt | 151 ++++++++++++++++++ .../lms/progress/domain/StudentLabLevel.kt | 23 +++ .../lms/progress/domain/VariantAssignment.kt | 13 -- .../repo/StudentLabLevelRepository.kt | 13 ++ .../repo/VariantAssignmentRepository.kt | 12 ++ .../src/main/resources/application.yml | 8 +- .../ru/ysu/lms/web/admin/AdminController.kt | 93 +++++++++++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 74 +++++++++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 86 ++++++++++ .../ysu/lms/web/student/StudentController.kt | 15 +- .../main/resources/templates/admin/lab.html | 43 +++++ .../main/resources/templates/admin/labs.html | 39 +++++ .../templates/student/dashboard.html | 27 ++-- 18 files changed, 753 insertions(+), 26 deletions(-) create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/StudentLabLevel.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt create mode 100644 web/src/main/resources/templates/admin/lab.html create mode 100644 web/src/main/resources/templates/admin/labs.html 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 ddba623..fb2b45b 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 @@ -67,6 +67,7 @@ data class LabDetail( ) data class LabLevelDetail( + val id: String = "", val code: String, val title: String, val variants: List, 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 dc5d92c..f738f95 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 @@ -112,6 +112,7 @@ class LabService( val vs = variantsRepo.findByLabIdOrderByNumber(lab.id) .filter { it.labLevel?.id == level.id } LabLevelDetail( + id = level.id.toString(), code = level.code, title = level.title, variants = vs.map { v -> diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt new file mode 100644 index 0000000..8b2323b --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt @@ -0,0 +1,46 @@ +package ru.ysu.lms.progress + +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import ru.ysu.lms.common.NotFoundException + +@Service +class AcademicClient( + @Value("\${lms.academic.base-url}") baseUrl: String, +) { + private val client = RestClient.builder().baseUrl(baseUrl).build() + + fun getLab(labId: String, token: String): LabInfo = + client.get().uri("/api/v1/labs/{id}", labId) + .header("Authorization", "Bearer $token") + .retrieve().body(LabInfo::class.java) + ?: throw NotFoundException("Лабораторная не найдена") + + fun getLabSummary(labId: String, token: String): LabInfo = + client.get().uri("/api/v1/labs/{id}", labId) + .header("Authorization", "Bearer $token") + .retrieve().body(LabInfo::class.java) + ?: throw NotFoundException("Лабораторная не найдена") +} + +@Service +class IdentityClient( + @Value("\${lms.identity.base-url}") baseUrl: String, +) { + private val client = RestClient.builder().baseUrl(baseUrl).build() + + fun getGroup(groupId: String, token: String): GroupInfo = + client.get().uri("/api/v1/groups/{id}", groupId) + .header("Authorization", "Bearer $token") + .retrieve().body(GroupInfo::class.java) + ?: throw NotFoundException("Группа не найдена") + + fun listGroups(token: String): List> { + val type = object : ParameterizedTypeReference>>() {} + return client.get().uri("/api/v1/groups") + .header("Authorization", "Bearer $token") + .retrieve().body(type) ?: emptyList() + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..bb5a9c5 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -0,0 +1,84 @@ +package ru.ysu.lms.progress + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +data class LevelAssignmentRequest( + val studentId: String, + val labId: String, + val labLevelId: String, +) + +data class LevelAssignmentResponse( + val studentId: String, + val labId: String, + val labLevelId: String, + val manual: Boolean, +) + +data class DistributeRequest( + val labId: String, + /** If true, re-assigns even existing manual assignments */ + val force: Boolean = false, +) + +data class VariantAssignmentResponse( + val studentId: String, + val labId: String, + val variantId: String, + val variantNumber: String, + val manual: Boolean, +) + +data class DistributeResult( + val labId: String, + val labTitle: String, + val assigned: Int, + val total: Int, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class StudentInfo( + val id: String = "", + val studentNo: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GroupInfo( + val id: String = "", + val name: String = "", + val students: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LabInfo( + val id: String = "", + val number: Int = 0, + val title: String = "", + val levels: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LevelInfo( + val code: String = "", + val title: String = "", + val id: String = "", + val variants: List = emptyList(), +) + +data class VariantInfo( + val id: String = "", + val number: String = "", + val title: String = "", +) + +data class MeAssignmentResponse( + val labNumber: Int, + val labTitle: String, + val variantNumber: String, + val variantTitle: String, + val statementMd: String, + val levelTitle: String, + val deadlineAt: String?, + val defenseAt: String?, + val pointsMax: Any?, +) \ 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 new file mode 100644 index 0000000..df1a20a --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -0,0 +1,50 @@ +package ru.ysu.lms.progress + +import jakarta.validation.Valid +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.* +import java.util.UUID + +@RestController +@RequestMapping("/api/v1") +class ProgressController(private val service: ProgressService) { + + @GetMapping("/me/assignments") + fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List { + val sid = UUID.fromString(jwt.subject) + return service.getStudentAssignments(sid).mapNotNull { va -> + service.getStudentAssignment(sid, va.labId, jwt.tokenValue) + } + } + + @GetMapping("/me/labs") + fun meLabs(@AuthenticationPrincipal jwt: Jwt): List> { + return listOf() + } + + @PostMapping("/levels") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun setLevel(@Valid @RequestBody body: LevelAssignmentRequest): LevelAssignmentResponse = + service.setLevel(body) + + @GetMapping("/levels") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun getLevels(@RequestParam labId: UUID): List = + service.getLevelsForLab(labId).map { sll -> + LevelAssignmentResponse( + studentId = sll.studentId.toString(), + labId = sll.labId.toString(), + labLevelId = sll.labLevelId.toString(), + manual = sll.manual, + ) + } + + @PostMapping("/distribute") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun distribute( + @Valid @RequestBody body: DistributeRequest, + @AuthenticationPrincipal jwt: Jwt, + ): DistributeResult = service.distribute(body, jwt.tokenValue) +} \ 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 new file mode 100644 index 0000000..4fad94b --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -0,0 +1,151 @@ +package ru.ysu.lms.progress + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.progress.domain.StudentLabLevel +import ru.ysu.lms.progress.domain.VariantAssignment +import ru.ysu.lms.progress.repo.StudentLabLevelRepository +import ru.ysu.lms.progress.repo.VariantAssignmentRepository +import ru.ysu.lms.progress.domain.StudentLabLevelKey +import java.util.UUID + +@Service +class ProgressService( + private val levels: StudentLabLevelRepository, + private val assignments: VariantAssignmentRepository, + private val academic: AcademicClient, + private val identity: IdentityClient, +) { + + @Transactional + fun setLevel(request: LevelAssignmentRequest): LevelAssignmentResponse { + val key = StudentLabLevelKey( + UUID.fromString(request.studentId), + UUID.fromString(request.labId), + ) + val existing = levels.findById(key) + val entity = existing.orElse(StudentLabLevel( + studentId = UUID.fromString(request.studentId), + labId = UUID.fromString(request.labId), + )) + entity.labLevelId = UUID.fromString(request.labLevelId) + entity.manual = true + levels.save(entity) + return LevelAssignmentResponse( + studentId = request.studentId, + labId = request.labId, + labLevelId = request.labLevelId, + manual = true, + ) + } + + @Transactional(readOnly = true) + fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? = + levels.findByStudentIdAndLabId(studentId, labId) + + @Transactional(readOnly = true) + fun getLevelsForLab(labId: UUID): List = + levels.findByLabId(labId) + + @Transactional + fun distribute(request: DistributeRequest, token: String): DistributeResult { + val labId = UUID.fromString(request.labId) + val lab = academic.getLab(request.labId, token) + val levelMap = lab.levels.associateBy { it.id } + + if (request.force) { + assignments.deleteByLabId(labId) + } + + val groups = identity.listGroups(token) + val students = mutableListOf>() // (studentId, labLevelId) + for (g in groups) { + val gid = g["id"] as String + val group = identity.getGroup(gid, token) + for (s in group.students) { + val sid = UUID.fromString(s.id) + val existingLevel = levels.findByStudentIdAndLabId(sid, labId) + val levelId = if (existingLevel != null) { + existingLevel.labLevelId + } else { + val levelInfo = lab.levels.firstOrNull() ?: continue + UUID.fromString(levelInfo.id) + } + students.add(sid to levelId) + } + } + + val assigned = mutableSetOf() + val variantIdsByLevel = lab.levels.associate { lv -> + lv.id to lv.variants.map { it.id } + } + + val result = mutableListOf() + for ((sid, levelId) in students) { + val existing = assignments.findByStudentIdAndLabId(sid, labId) + if (existing != null && existing.manual) { + result.add(existing) + assigned.add(existing.variantId) + continue + } + if (existing != null && !request.force) { + result.add(existing) + assigned.add(existing.variantId) + continue + } + + val pool = variantIdsByLevel[levelId.toString()].orEmpty() + if (pool.isEmpty()) continue + + val chosen = pool.firstOrNull { it !in assigned } + ?: pool.first() + val entity = VariantAssignment( + studentId = sid, + labId = labId, + variantId = UUID.fromString(chosen), + manual = false, + ) + assignments.save(entity) + result.add(entity) + assigned.add(UUID.fromString(chosen)) + } + + val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title + return DistributeResult( + labId = request.labId, + labTitle = title, + assigned = result.size, + total = students.size, + ) + } + + @Transactional(readOnly = true) + fun getAssignmentsForLab(labId: UUID): List = + assignments.findByLabId(labId) + + @Transactional(readOnly = true) + fun getStudentAssignments(studentId: UUID): List = + assignments.findByStudentId(studentId) + + @Transactional(readOnly = true) + fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { + val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null + return enrich(va, token) + } + + private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? { + val lab = runCatching { academic.getLabSummary(va.labId.toString(), token) }.getOrNull() ?: return null + val firstLevel = lab.levels.firstOrNull() + return MeAssignmentResponse( + labNumber = lab.number, + labTitle = lab.title, + variantNumber = "", + variantTitle = "", + statementMd = "", + levelTitle = firstLevel?.title ?: "", + deadlineAt = null, + defenseAt = null, + pointsMax = null, + ) + } +} \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/StudentLabLevel.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/StudentLabLevel.kt new file mode 100644 index 0000000..001a204 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/StudentLabLevel.kt @@ -0,0 +1,23 @@ +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.util.UUID + +@Entity +@Table(name = "student_lab_levels") +@IdClass(StudentLabLevelKey::class) +class StudentLabLevel( + @Id @Column(nullable = false) var studentId: UUID = UUID(0,0), + @Id @Column(nullable = false) var labId: UUID = UUID(0,0), + @Column(nullable = false) var labLevelId: UUID = UUID(0,0), + @Column(nullable = false) var manual: Boolean = false, +) + +data class StudentLabLevelKey( + val studentId: UUID = UUID(0,0), + val labId: UUID = UUID(0,0), +) : java.io.Serializable \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/VariantAssignment.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/VariantAssignment.kt index 9e54c60..240794b 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/VariantAssignment.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/VariantAssignment.kt @@ -17,17 +17,4 @@ class VariantAssignment( @Column(nullable = false) var variantId: UUID = UUID(0,0), @Column(nullable = false) var manual: Boolean = false, @Column(nullable = false) var assignedAt: Instant = Instant.now(), -) - -@Entity -@Table(name = "student_lab_levels") -class StudentLabLevel( - @Id - @Column(name = "student_id", insertable = false, updatable = false) - var studentId: UUID = UUID(0,0), - @Id - @Column(name = "lab_id", insertable = false, updatable = false) - var labId: UUID = UUID(0,0), - @Column(nullable = false) var labLevelId: UUID = UUID(0,0), - @Column(nullable = false) var manual: Boolean = false, ) \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt new file mode 100644 index 0000000..891ad78 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt @@ -0,0 +1,13 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.StudentLabLevel +import ru.ysu.lms.progress.domain.StudentLabLevelKey +import java.util.UUID + +interface StudentLabLevelRepository : JpaRepository { + fun findByLabId(labId: UUID): List + fun findByStudentId(studentId: UUID): List + fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel? + fun deleteByLabId(labId: UUID) +} \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt new file mode 100644 index 0000000..9b915df --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt @@ -0,0 +1,12 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.VariantAssignment +import java.util.UUID + +interface VariantAssignmentRepository : JpaRepository { + fun findByStudentId(studentId: UUID): List + fun findByLabId(labId: UUID): List + fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment? + fun deleteByLabId(labId: UUID) +} \ No newline at end of file diff --git a/services/progress-service/src/main/resources/application.yml b/services/progress-service/src/main/resources/application.yml index c20ee68..f118af2 100644 --- a/services/progress-service/src/main/resources/application.yml +++ b/services/progress-service/src/main/resources/application.yml @@ -27,4 +27,10 @@ management: include: health,info springdoc: api-docs: - path: /v3/api-docs \ No newline at end of file + path: /v3/api-docs + +lms: + academic: + base-url: ${LMS_ACADEMIC_URI:http://localhost:8082} + identity: + base-url: ${LMS_IDENTITY_URI:http://localhost:8081} \ 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 b1541d1..d545717 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 @@ -15,6 +15,11 @@ import org.springframework.web.multipart.MultipartFile 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.LabImportResultDto +import ru.ysu.lms.web.api.DistributeResultDto +import ru.ysu.lms.web.api.LevelAssignPayload +import ru.ysu.lms.web.api.LevelAssignmentResultDto +import ru.ysu.lms.web.api.DistributeRequestPayload import ru.ysu.lms.web.session.SessionKeys import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -118,6 +123,94 @@ class AdminController(private val api: ApiClient) { .body(download.bytes) } + @GetMapping("/labs") + fun labs(session: HttpSession, model: Model): String { + model.addAttribute("labs", api.listLabs(token(session))) + return "admin/labs" + } + + @PostMapping("/labs/import") + fun importLab( + @RequestParam("file") file: MultipartFile, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + if (file.isEmpty) { + redirect.addFlashAttribute("error", "Выберите файл") + return "redirect:/admin/labs" + } + return try { + val result = api.importLab( + token(session), + file.originalFilename ?: "lab.zip", + file.bytes, + ) + redirect.addFlashAttribute("notice", "Лабораторная ${result.number}: «${result.title}» импортирована (${result.variantCount} вариантов)") + "redirect:/admin/labs" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + } + + @PostMapping("/labs/{id}/delete") + fun deleteLab( + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.deleteLab(token(session), id) + redirect.addFlashAttribute("notice", "Лабораторная удалена") + "redirect:/admin/labs" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + + @GetMapping("/labs/{id}") + fun labDetail( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val lab = api.labDetail(token(session), id) + val levels = api.getLevels(token(session), id) + model.addAttribute("lab", lab) + model.addAttribute("levelAssignments", levels) + return "admin/lab" + } + + @PostMapping("/labs/{id}/distribute") + fun distribute( + @PathVariable id: String, + @RequestParam(defaultValue = "false") force: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val result = api.distribute(token(session), DistributeRequestPayload(labId = id, force = force)) + redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") + "redirect:/admin/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id" + } + + @PostMapping("/labs/{labId}/set-level") + fun setLevel( + @PathVariable labId: String, + @RequestParam studentId: String, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) + redirect.addFlashAttribute("notice", "Уровень студента установлен") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + 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/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 4a5d02c..3c98de3 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 @@ -143,6 +143,80 @@ class ApiClient( return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})" } + fun listLabs(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/labs") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun labDetail(token: String, labId: String): LabDetailDto = + call { + client.get().uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(LabDetailDto::class.java) + } + + fun importLab(token: String, filename: String, bytes: ByteArray): LabImportResultDto { + val parts = LinkedMultiValueMap() + parts.add("file", object : ByteArrayResource(bytes) { + override fun getFilename(): String = filename + }) + return call { + client.post().uri("/api/v1/labs/import") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve().body(LabImportResultDto::class.java) + } + } + + fun deleteLab(token: String, labId: String) { + call { + client.delete().uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun getLevels(token: String, labId: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/levels?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun setLevel(token: String, payload: LevelAssignPayload): LevelAssignmentResultDto = + call { + client.post().uri("/api/v1/levels") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(LevelAssignmentResultDto::class.java) + } + + fun distribute(token: String, payload: DistributeRequestPayload): DistributeResultDto = + call { + client.post().uri("/api/v1/distribute") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(DistributeResultDto::class.java) + } + + fun meAssignments(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/me/assignments") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + 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 9945510..038bcee 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 @@ -71,6 +71,92 @@ data class GroupDetailDto( @JsonIgnoreProperties(ignoreUnknown = true) data class ResetPasswordDto(val password: String = "") +@JsonIgnoreProperties(ignoreUnknown = true) +data class LabSummaryDto( + val id: String = "", + val number: Int = 0, + val title: String = "", + val status: String = "", + val semesterTitle: String = "", + val pointsMax: Any? = null, + val variantCount: Int = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LabImportResultDto( + val labId: String = "", + val number: Int = 0, + val title: String = "", + val levelCount: Int = 0, + val variantCount: Int = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DistributeResultDto( + val labId: String = "", + val labTitle: String = "", + val assigned: Int = 0, + val total: Int = 0, +) + +data class DistributeRequestPayload( + val labId: String, + val force: Boolean = false, +) + +data class LevelAssignPayload( + val studentId: String, + val labId: String, + val labLevelId: String, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LevelAssignmentResultDto( + val studentId: String = "", + val labId: String = "", + val labLevelId: String = "", + val manual: Boolean = false, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class MeAssignmentDto( + val labNumber: Int = 0, + val labTitle: String = "", + val variantNumber: String = "", + val variantTitle: String = "", + val statementMd: String = "", + val levelTitle: String = "", + val deadlineAt: String? = null, + val defenseAt: String? = null, + val pointsMax: Any? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LabDetailDto( + val id: String = "", + val number: Int = 0, + val title: String = "", + val status: String = "", + val descriptionMd: String = "", + val levels: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LabLevelDetailDto( + val id: String = "", + val code: String = "", + val title: String = "", + val variants: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class VariantDetailDto( + val id: String = "", + val number: String = "", + val title: String = "", + val statementMd: String = "", +) + class DownloadDto( val bytes: ByteArray, val filename: 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 15b0cda..70582e2 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 @@ -1,13 +1,22 @@ package ru.ysu.lms.web.student +import jakarta.servlet.http.HttpSession import org.springframework.stereotype.Controller +import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.session.SessionKeys @Controller @RequestMapping("/student") -class StudentController { +class StudentController(private val api: ApiClient) { @GetMapping - fun dashboard(): String = "student/dashboard" -} + fun dashboard(session: HttpSession, model: Model): String { + val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login" + val assignments = api.meAssignments(token) + model.addAttribute("assignments", assignments) + return "student/dashboard" + } +} \ 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 new file mode 100644 index 0000000..15d6bd0 --- /dev/null +++ b/web/src/main/resources/templates/admin/lab.html @@ -0,0 +1,43 @@ + + +Лабораторная + +
+ ← Все лабораторные +
+

ЛР 1

Статус: —

+
+ + +
+
+
+
+ +

Уровни вариантов

+
+ + + + + + + + + +
КодНазваниеВариантов
——0
+
+ +

Студенты и варианты

+

Нажмите «Распределить», чтобы закрепить варианты. Можно задать уровень студента.

+
+ + + + + +
СтудентУровеньВариант
+
+
+ + \ No newline at end of file diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html new file mode 100644 index 0000000..a05ee0a --- /dev/null +++ b/web/src/main/resources/templates/admin/labs.html @@ -0,0 +1,39 @@ + + +Лабораторные — LMS + +
+

Лабораторные

+
+
+
+
+

Импорт лабораторной

+

Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json (старый формат).

+
+ +
+
+
+ + + + + + + + + + + + + +
№НазваниеСеместрВариантовСтатус
1——0— +
+ +
+
Лабораторных пока нет.
+
+
+ + \ No newline at end of file diff --git a/web/src/main/resources/templates/student/dashboard.html b/web/src/main/resources/templates/student/dashboard.html index 593a6f8..e0f020c 100644 --- a/web/src/main/resources/templates/student/dashboard.html +++ b/web/src/main/resources/templates/student/dashboard.html @@ -4,15 +4,24 @@

Здравствуйте, студент

-

Здесь появятся лабораторные работы, условия вашего варианта и материалы занятий.

-
-

Разделы в разработке:

-
    -
  • список лабораторных и сроки сдачи;
  • -
  • условие закреплённого за вами варианта;
  • -
  • статьи и материалы занятий;
  • -
  • отправка решений.
  • -
+

Ваши лабораторные работы

+
+ Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты. +
+
+ + + + + + + + + + + + +
№НазваниеВариантСрок сдачиДата защитыСтатус
1————назначено