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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user