diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index d587354..c996a28 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/**,/api/v1/grades,/api/v1/grades/**,/api/v1/submissions/** + - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/submissions/** management: endpoints: diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt new file mode 100644 index 0000000..f5d4754 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt @@ -0,0 +1,48 @@ +package ru.ysu.lms.progress + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* +import java.util.UUID + +data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String) + +data class DefenseAdminDto( + val id: String, + val studentId: String, + val labId: String, + val status: String, + val code: String?, + val assignmentMd: String, + val startedAt: String?, + val readyAt: String?, + val readyReason: String?, + val secondsLeft: Long?, +) + +@RestController +@RequestMapping("/api/v1/defenses") +@PreAuthorize("hasAuthority('ROLE_ADMIN')") +class DefenseAdminController(private val service: DefenseService) { + + @PostMapping("/assign") + fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto = + service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd) + + @DeleteMapping("/assign") + fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) { + service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId)) + } + + @GetMapping + fun list(@RequestParam labId: UUID): List = + service.listForLab(labId).map { + DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd, + it.startedAt, it.readyAt, it.readyReason, it.secondsLeft) + } + + @PostMapping("/{id}/issue-code") + fun issueCode(@PathVariable id: UUID): Map = mapOf("code" to service.issueCode(id)) + + @PostMapping("/{id}/reset") + fun reset(@PathVariable id: UUID) = service.reset(id) +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt new file mode 100644 index 0000000..acdabe0 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt @@ -0,0 +1,38 @@ +package ru.ysu.lms.progress + +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/me/defenses") +class DefenseMyController(private val service: DefenseService) { + + data class StartByCodePayload(val code: String) + + @GetMapping + fun my(@AuthenticationPrincipal jwt: Jwt): List = + service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) } + + @GetMapping("/{labId}") + fun one( + @PathVariable labId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) } + + private fun hideCode(d: DefenseDto): DefenseDto = + if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d + + @PostMapping("/start") + fun start( + @RequestBody body: StartByCodePayload, + @AuthenticationPrincipal jwt: Jwt, + ): DefenseDto = service.startByCode(UUID.fromString(jwt.subject), body.code) + + @PostMapping("/{id}/finish") + fun finish( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): DefenseDto = service.finish(UUID.fromString(jwt.subject), id) +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt new file mode 100644 index 0000000..397e102 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt @@ -0,0 +1,173 @@ +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.ForbiddenException +import ru.ysu.lms.common.NotFoundException +import ru.ysu.lms.progress.domain.DefenseSession +import ru.ysu.lms.progress.repo.DefenseSessionRepository +import java.security.SecureRandom +import java.time.Duration +import java.time.Instant +import java.util.UUID + +enum class DefenseStatus { waiting, started, ready } + +data class DefenseDto( + val id: String, + val studentId: String, + val labId: String, + val status: String, + val code: String?, + val assignmentMd: String, + val startedAt: String?, + val readyAt: String?, + val readyReason: String?, + val secondsLeft: Long?, +) + +@Service +class DefenseService( + private val sessions: DefenseSessionRepository, +) { + + companion object { + val DURATION: Duration = Duration.ofMinutes(30) + private val random = SecureRandom() + } + + @Transactional + fun syncTimeouts(): Int { + val now = Instant.now() + var changed = 0 + for (s in sessions.findByStatus(DefenseStatus.started.name)) { + val started = s.startedAt ?: continue + if (started.plus(DURATION).isBefore(now)) { + s.status = DefenseStatus.ready.name + s.readyAt = now + s.readyReason = "timeout" + sessions.save(s) + changed++ + } + } + return changed + } + + @Transactional + fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto { + val existing = sessions.findByStudentIdAndLabId(studentId, labId) + val s = existing ?: DefenseSession(studentId = studentId, labId = labId) + // смена задания сбрасывает активную попытку защиты + s.assignmentMd = assignmentMd + if (existing == null || s.status != DefenseStatus.waiting.name) { + s.status = DefenseStatus.waiting.name + s.code = null + s.startedAt = null + s.readyAt = null + s.readyReason = null + } + return toDto(sessions.save(s)) + } + + @Transactional + fun issueCode(id: UUID): String { + val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") } + repeat(20) { + val code = (random.nextInt(900_000) + 100_000).toString() + if (sessions.findByCode(code) == null) { + s.code = code + s.status = DefenseStatus.waiting.name + s.startedAt = null + s.readyAt = null + s.readyReason = null + sessions.save(s) + return code + } + } + throw BadRequestException("Не удалось выдать код, попробуйте ещё раз") + } + + @Transactional + fun reset(id: UUID) { + val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") } + s.status = DefenseStatus.waiting.name + s.code = null + s.startedAt = null + s.readyAt = null + s.readyReason = null + sessions.save(s) + } + + @Transactional(readOnly = true) + fun listForLab(labId: UUID): List { + syncTimeouts() + return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) } + } + + @Transactional + fun startByCode(studentId: UUID, code: String): DefenseDto { + syncTimeouts() + val s = sessions.findByCode(code.trim()) + ?: throw BadRequestException("Код не найден") + if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту") + if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата") + s.status = DefenseStatus.started.name + s.startedAt = Instant.now() + return toDto(sessions.save(s)) + } + + @Transactional + fun finish(studentId: UUID, sessionId: UUID): DefenseDto { + val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") } + if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия") + if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата") + s.status = DefenseStatus.ready.name + s.readyAt = Instant.now() + s.readyReason = "button" + return toDto(sessions.save(s)) + } + + @Transactional + fun mySessions(studentId: UUID): List { + syncTimeouts() + return sessions.findByStudentId(studentId).map { toDto(it) } + } + + @Transactional + fun mySession(studentId: UUID, labId: UUID): DefenseDto? { + syncTimeouts() + return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) } + } + + @Transactional + fun resetByStudentLab(studentId: UUID, labId: UUID) { + val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return + s.status = DefenseStatus.waiting.name + s.code = null + s.startedAt = null + s.readyAt = null + s.readyReason = null + sessions.save(s) + } + + private fun toDto(s: DefenseSession): DefenseDto { + val left = if (s.status == DefenseStatus.started.name) { + val started = s.startedAt ?: Instant.now() + val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0) + remain + } else null + return DefenseDto( + id = s.id.toString(), + studentId = s.studentId.toString(), + labId = s.labId.toString(), + status = s.status, + code = s.code, + assignmentMd = if (s.status == DefenseStatus.waiting.name) "" else s.assignmentMd, + startedAt = s.startedAt?.toString(), + readyAt = s.readyAt?.toString(), + readyReason = s.readyReason, + secondsLeft = left, + ) + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/DefenseSession.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/DefenseSession.kt new file mode 100644 index 0000000..7ad9fb5 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/DefenseSession.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.Table +import ru.ysu.lms.common.Ids +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "defense_sessions") +class DefenseSession( + @Id var id: UUID = Ids.v7(), + @Column(nullable = false) var studentId: UUID = UUID(0, 0), + @Column(nullable = false) var labId: UUID = UUID(0, 0), + @Column(name = "assignment_md", nullable = false) var assignmentMd: String = "", + @Column(nullable = false) var status: String = "waiting", + var code: String? = null, + @Column(name = "started_at") var startedAt: Instant? = null, + @Column(name = "ready_at") var readyAt: Instant? = null, + @Column(name = "ready_reason") var readyReason: String? = null, +) \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt new file mode 100644 index 0000000..eaf1dcc --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt @@ -0,0 +1,13 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.DefenseSession +import java.util.UUID + +interface DefenseSessionRepository : JpaRepository { + fun findByLabIdOrderByStudentIdAsc(labId: UUID): List + fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession? + fun findByStudentId(studentId: UUID): List + fun findByStatus(status: String): List + fun findByCode(code: String): DefenseSession? +} diff --git a/services/progress-service/src/main/resources/db/migration/V5__defense_sessions.sql b/services/progress-service/src/main/resources/db/migration/V5__defense_sessions.sql new file mode 100644 index 0000000..9405721 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V5__defense_sessions.sql @@ -0,0 +1,16 @@ +-- Defense sessions: individual task + invite code + 30-minute timer. + +CREATE TABLE defense_sessions ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + student_id uuid NOT NULL, + lab_id uuid NOT NULL, + assignment_md text NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'waiting', -- waiting | started | ready + code varchar(6), + started_at timestamptz, + ready_at timestamptz, + ready_reason varchar(16), -- button | timeout + CONSTRAINT defense_session_unique UNIQUE (student_id, lab_id) +); + +CREATE INDEX defense_sessions_lab_idx ON defense_sessions (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 5e27444..e0bfb65 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 @@ -252,6 +252,7 @@ class AdminController( val levelAssignments = api.getLevels(t, id) val variantAssignments = api.getLabAssignments(t, id) val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } + val defensesByStudent = api.labDefenses(t, id).associateBy { it.studentId } // Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом val groupSections = api.listGroups(t).map { group -> @@ -280,6 +281,12 @@ class AdminController( }, submissionId = sub?.id, submissionSeq = sub?.seq, + defenseSessionId = defensesByStudent[s.id]?.id, + defenseStatus = defensesByStudent[s.id]?.status, + defenseCode = defensesByStudent[s.id]?.takeIf { it.status == "waiting" }?.code, + defenseSecondsLeft = defensesByStudent[s.id]?.secondsLeft, + defenseAssigned = defensesByStudent[s.id]?.assignmentMd?.isNotBlank() == true, + adminAssignmentMd = defensesByStudent[s.id]?.assignmentMd ?: "", ) }, ) @@ -291,9 +298,91 @@ class AdminController( model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt)) model.addAttribute("deadlineLocal", ru.ysu.lms.web.DateUtils.localInput(lab.deadlineAt)) model.addAttribute("defenseLocal", ru.ysu.lms.web.DateUtils.localInput(lab.defenseAt)) + model.addAttribute("defensesByStudent", defensesByStudent) return "admin/lab" } + @PostMapping("/labs/{labId}/defense/assign") + fun assignDefense( + @PathVariable labId: String, + @RequestParam studentId: String, + @RequestParam assignmentMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.assignDefense(token(session), studentId, labId, assignmentMd) + redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/{labId}/defense/import") + fun importDefenses( + @PathVariable labId: String, + @RequestParam("file") file: MultipartFile, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val t = token(session) + return try { + val map = mutableMapOf() + for (g in api.listGroups(t)) { + api.groupDetail(t, g.id).students.forEach { map[it.studentNo] = it.id } + } + val text = String(file.bytes, Charsets.UTF_8).removePrefix("\uFEFF") + var count = 0 + for (raw in text.lineSequence()) { + val line = raw.trim() + if (line.isBlank()) continue + if (line.contains("студен", ignoreCase = true) && line.contains(";")) continue + val pair = line.split(";", limit = 2) + if (pair.size < 2) continue + val sid = map[pair[0].trim()] ?: continue + val md = pair[1].trim().replace("\\n", "\n") + if (md.isEmpty()) continue + api.assignDefense(t, sid, labId, md) + count++ + } + redirect.addFlashAttribute("notice", "Импортировано заданий: $count") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + } + + @PostMapping("/defenses/{id}/issue-code") + fun issueDefenseCode( + @PathVariable id: String, + @RequestParam labId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val code = api.issueDefenseCode(token(session), id) + redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/defenses/{id}/reset") + fun resetDefenseSession( + @PathVariable id: String, + @RequestParam labId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.resetDefense(token(session), id) + redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + @GetMapping("/submissions/{id}/download") fun downloadSubmissionAsAdmin( @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 91d1cc7..1be002f 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 @@ -17,6 +17,12 @@ data class AdminLabStudent( val source: String, val submissionId: String? = null, val submissionSeq: Int? = null, + val defenseStatus: String? = null, + val defenseCode: String? = null, + val defenseSessionId: String? = null, + val defenseSecondsLeft: Long? = null, + val defenseAssigned: Boolean = false, + val adminAssignmentMd: String = "", ) data class AdminStudentGrades( 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 76f23af..db20ff7 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 @@ -123,6 +123,13 @@ class ApiClient( private fun bearer(token: String): String = "Bearer $token" + private fun callOrNull(block: () -> T?): T? = + try { + block() + } catch (ex: RestClientResponseException) { + null + } + private fun call(block: () -> T?): T = try { block() ?: throw ApiException(502, "Пустой ответ сервиса") @@ -583,6 +590,74 @@ class ApiClient( .retrieve().body(ContentArticleDto::class.java) } + // ---------- Defense (индивидуальные задания защиты) ---------- + + fun labDefenses(token: String, labId: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/defenses?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun assignDefense(token: String, studentId: String, labId: String, assignmentMd: String): DefenseDto = + call { + client.post().uri("/api/v1/defenses/assign") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("studentId" to studentId, "labId" to labId, "assignmentMd" to assignmentMd)) + .retrieve().body(DefenseDto::class.java) + } + + fun issueDefenseCode(token: String, sessionId: String): String = + call { + client.post().uri("/api/v1/defenses/{id}/issue-code", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(Map::class.java) + }?.get("code").toString() + + fun resetDefense(token: String, sessionId: String) { + call { + client.post().uri("/api/v1/defenses/{id}/reset", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun myDefenses(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/me/defenses") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun myDefense(token: String, labId: String): DefenseDto? = + callOrNull { + client.get().uri("/api/v1/me/defenses/{labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(DefenseDto::class.java) + } + + fun startDefense(token: String, code: String): DefenseDto = + call { + client.post().uri("/api/v1/me/defenses/start") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("code" to code)) + .retrieve().body(DefenseDto::class.java) + } + + fun finishDefense(token: String, sessionId: String): DefenseDto = + call { + client.post().uri("/api/v1/me/defenses/{id}/finish", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .retrieve().body(DefenseDto::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 01cb32e..dc1713a 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 @@ -332,3 +332,19 @@ class DownloadDto( val filename: String, val contentType: String, ) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DefenseDto( + val id: String = "", + val studentId: String = "", + val labId: String = "", + val status: String = "none", + val code: String? = null, + val assignmentMd: String = "", + val startedAt: String? = null, + val readyAt: String? = null, + val readyReason: String? = null, + val secondsLeft: Long? = null, +) { + var assignmentHtml: 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 15482a5..e166b0b 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 @@ -87,6 +87,9 @@ class StudentController( model.addAttribute("grade", api.myGrades(t).firstOrNull { it.labId == id }?.score) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) model.addAttribute("canSubmit", canSubmit(lab.deadlineAt)) + val defense = api.myDefense(t, id) + defense?.let { it.assignmentHtml = markdown.render(it.assignmentMd) } + model.addAttribute("defense", defense) "student/lab" } catch (ex: ApiException) { "redirect:/student/labs" @@ -114,6 +117,42 @@ class StudentController( } } + @PostMapping("/defenses/start") + fun startDefense( + @RequestParam labId: String, + @RequestParam code: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val t = token(session) ?: return "redirect:/login" + return try { + api.startDefense(t, code) + redirect.addFlashAttribute("notice", "Защита начата. У вас 30 минут.") + "redirect:/student/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/student/labs/$labId" + } + } + + @PostMapping("/defenses/{id}/finish") + fun finishDefense( + @PathVariable id: String, + @RequestParam labId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val t = token(session) ?: return "redirect:/login" + return try { + api.finishDefense(t, id) + redirect.addFlashAttribute("notice", "Готово! Преподаватель увидит, что вы завершили защиту.") + "redirect:/student/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/student/labs/$labId" + } + } + @GetMapping("/submissions/{id}/download") fun download(@PathVariable id: String, session: HttpSession): ResponseEntity { val t = token(session) ?: return ResponseEntity.status(401).build() diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index a83ec9b..14ed4a9 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -98,7 +98,7 @@

Группа

- + @@ -134,14 +134,63 @@ th:href="'/admin/submissions/' + ${s.submissionId} + '/download'" th:text="|попытка ${s.submissionSeq}|">↓не сдано + - +
СтудентЛогинУровеньВариантИсточникСдача
СтудентЛогинУровеньВариантИсточникСдачаЗащита
— + не назначено + код + ждёт задания + на защите (с) + готов к защите +
+ + + + +
+
В группе нет студентов.
В группе нет студентов.
+

Индивидуальные задания для защиты

+
+ +
+ + + +
+
+

CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.

+ + + + + + + + +

Сдача работы

@@ -56,6 +99,5 @@
- - +