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 a3be71db27
commit ccd31263d5
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);