Phase 3: individual defense assignments with invite codes

- progress V5 defense_sessions: per student+lab individual task (md), status
  machine waiting -> started -> ready, one-time 6-digit codes
- 30-min timer with server-side lazy expiry (syncTimeouts: started->ready by
  timeout), countdown JS on student page
- admin: assign task dialog (+prefill existing via data-md), CSV import
  (studentNo;task, \n for newlines) resolved via identity students,
  issue/reset code buttons, defense status column
- student: code entry form (code hidden until start), assignment md,
  'Готов к сдаче', waiting-for-teacher note
- gateway: /api/v1/defenses,/api/v1/defenses/** routes
- web api client + admin/student controllers wired
This commit is contained in:
2026-09-12 00:54:10 +03:00
parent e1da254599
commit d6fc47794e
14 changed files with 634 additions and 6 deletions

View File

@@ -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:

View File

@@ -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<DefenseAdminDto> =
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<String, String> = mapOf("code" to service.issueCode(id))
@PostMapping("/{id}/reset")
fun reset(@PathVariable id: UUID) = service.reset(id)
}

View File

@@ -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<DefenseDto> =
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)
}

View File

@@ -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<DefenseDto> {
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<DefenseDto> {
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,
)
}
}

View File

@@ -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,
)

View File

@@ -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<DefenseSession, UUID> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
fun findByStudentId(studentId: UUID): List<DefenseSession>
fun findByStatus(status: String): List<DefenseSession>
fun findByCode(code: String): DefenseSession?
}

View File

@@ -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);

View File

@@ -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<String, String>()
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,

View File

@@ -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(

View File

@@ -123,6 +123,13 @@ class ApiClient(
private fun bearer(token: String): String = "Bearer $token"
private fun <T> callOrNull(block: () -> T?): T? =
try {
block()
} catch (ex: RestClientResponseException) {
null
}
private fun <T> 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<DefenseDto> {
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
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<DefenseDto> {
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
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*\"((?:\\\\.|[^\"\\\\])*)\"")
}

View File

@@ -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 = ""
}

View File

@@ -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<ByteArray> {
val t = token(session) ?: return ResponseEntity.status(401).build()

View File

@@ -98,7 +98,7 @@
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6">
<table>
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th></tr></thead>
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th><th>Защита</th></tr></thead>
<tbody>
<tr th:each="s : ${g.students}">
<td th:text="${s.fullName}">—</td>
@@ -134,14 +134,63 @@
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
th:text="|попытка ${s.submissionSeq}|">↓</a>
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
<td>
<span th:if="${s.defenseStatus == null}" class="text-muted">не назначено</span>
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode != null}" class="badge badge-warn">код <span class="font-mono" th:text="${s.defenseCode}"></span></span>
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode == null}" class="badge badge-warn">ждёт задания</span>
<span th:if="${s.defenseStatus == 'started'}" class="badge badge-warn">на защите (<span th:text="${s.defenseSecondsLeft}"></span>с)</span>
<span th:if="${s.defenseStatus == 'ready'}" class="badge badge-ok">готов к защите</span>
<form class="inline-form" th:if="${s.defenseSessionId != null}" th:action="'/admin/defenses/' + ${s.defenseSessionId} + '/issue-code'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus != 'ready'}">выдать код</button>
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus == 'ready'}">заново</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="7" class="empty">В группе нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="8" class="empty">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
<h2 class="section-title mt-8">Индивидуальные задания для защиты</h2>
<div class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary" type="button" onclick="openModal('modal-defense-assign')">Выдать / изменить задание</button>
<form class="inline-form" th:action="'/admin/labs/' + ${lab.id} + '/defense/import'" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" type="file" name="file" accept=".csv" required>
<button class="btn btn-secondary btn-sm" type="submit">Импорт CSV</button>
</form>
</div>
<p class="text-sm text-muted mb-4">CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.</p>
<dialog id="modal-defense-assign" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-assign')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="'/admin/labs/' + ${lab.id} + '/defense/assign'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Студент</label>
<select class="input" id="defense-student" name="studentId" required onchange="fillAssignment(this)">
<option value="">— выберите студента —</option>
<optgroup th:each="g : ${groups}" th:label="${g.groupName}">
<option th:each="s : ${g.students}" th:value="${s.studentId}"
th:attr="data-md=${s.adminAssignmentMd}"
th:text="${s.fullName}">—</option>
</optgroup>
</select></div>
<div><label class="label">Задание (markdown)</label>
<textarea class="input" id="defense-md" name="assignmentMd" rows="8" required></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить задание</button>
</form>
</div>
</dialog>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
@@ -255,6 +304,7 @@
</th:block>
<script>
function fillAssignment(sel) { document.getElementById("defense-md").value = sel.options[sel.selectedIndex].getAttribute("data-md") || ""; }
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);

View File

@@ -28,7 +28,50 @@
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
</div>
<h2 class="section-title">Сдача работы</h2>
<h2 class="section-title">Защита лабораторной</h2>
<div class="card card-body form-stack">
<div th:if="${defense == null}" class="text-muted">
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
</div>
<th:block th:if="${defense != null and defense.status == 'waiting'}">
<p class="text-sm">Подойдите к преподавателю: он выдаст вам одноразовый код для начала защиты.</p>
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input class="input input-sm" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}" required>
<button class="btn btn-primary" type="submit">Приступить к защите</button>
</form>
</th:block>
<div th:if="${defense != null and defense.status == 'started'}">
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}" data-labid="${lab.id}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>
</div>
<script>
document.querySelectorAll('.defense-timer').forEach(function (el) {
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = setInterval(function () {
left -= 1;
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
var m = Math.floor(left / 60), s = left % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
}, 1000);
var m = Math.floor(left / 60), s = left % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
});
</script>
<div th:if="${defense != null and defense.status == 'ready'}">
<p class="badge badge-ok defense-note">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
</div>
</div>
<h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4">
<form th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
@@ -56,6 +99,5 @@
</tbody>
</table>
</div>
</div>
</body>
</div></body>
</html>