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:
@@ -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);
|
||||
Reference in New Issue
Block a user