Phase 2+5: lab submissions and student lab flow
- progress: submissions table+V4; SubmissionService (deadline-gated submit,
unlimited resubmits, per-student history, zip download); files on disk
data/submissions/{student}/{lab}/{seq}/
- student API: POST submit (multipart), GET me submissions, download; admin
GET lab submissions + admin download (zip pack)
- progress enrich(): labId in MeAssignment; web DTO aligned
- web: /student dashboard (avg/best/worst cards), /student/labs cards,
/student/labs/{id}: condition md, variant block, submit form, history;
'Лабораторные' link in nav for students
- admin: submissions column on lab assignment table + download link
- fixed long-standing template bug: broken @{} expression (missing }) in
admin/lab and student/lab
This commit is contained in:
@@ -81,6 +81,7 @@ data class VariantInfo(
|
||||
)
|
||||
|
||||
data class MeAssignmentResponse(
|
||||
val labId: String,
|
||||
val labNumber: Int,
|
||||
val labTitle: String,
|
||||
val variantNumber: String,
|
||||
@@ -133,4 +134,13 @@ data class GradeResponse(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val score: BigDecimal,
|
||||
)
|
||||
|
||||
data class SubmissionDto(
|
||||
val id: String,
|
||||
val seq: Int,
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val createdAt: String,
|
||||
val files: List<String>,
|
||||
)
|
||||
@@ -1,16 +1,23 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class ProgressController(
|
||||
private val service: ProgressService,
|
||||
private val submissions: SubmissionService,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@@ -76,4 +83,49 @@ class ProgressController(
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
|
||||
service.listGrades().filter { it.studentId == jwt.subject }
|
||||
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@PathVariable labId: UUID,
|
||||
@RequestPart("files") files: List<MultipartFile>,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
val all = submissions.submit(sid, labId, files, jwt.tokenValue)
|
||||
return submissions.mySubmissions(sid, labId)
|
||||
}
|
||||
|
||||
@GetMapping("/me/submissions")
|
||||
fun mySubmissions(
|
||||
@RequestParam labId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
||||
|
||||
@GetMapping("/me/submissions/{id}/download")
|
||||
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
|
||||
return attachment(bytes, name)
|
||||
}
|
||||
|
||||
@GetMapping("/submissions")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
|
||||
submissions.latestPerStudent(labId)
|
||||
|
||||
@GetMapping("/submissions/{id}/download")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
|
||||
return attachment(bytes, name)
|
||||
}
|
||||
|
||||
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
|
||||
)
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes)
|
||||
}
|
||||
@@ -202,6 +202,7 @@ class ProgressService(
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
|
||||
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
|
||||
return MeAssignmentResponse(
|
||||
labId = va.labId.toString(),
|
||||
labNumber = lab.number,
|
||||
labTitle = lab.title,
|
||||
variantNumber = variant?.number ?: "",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import java.nio.file.Files
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class SubmissionService(
|
||||
private val submissions: SubmissionRepository,
|
||||
private val storage: SubmissionStorage,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun submit(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
files: List<MultipartFile>,
|
||||
token: String,
|
||||
): SubmissionDto {
|
||||
if (files.isEmpty() || files.all { it.isEmpty }) {
|
||||
throw BadRequestException("Приложите файлы или архив")
|
||||
}
|
||||
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
if (deadline != null && Instant.now().isAfter(deadline)) {
|
||||
throw BadRequestException("Срок сдачи истёк")
|
||||
}
|
||||
val last = submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
||||
val seq = (last?.seq ?: 0) + 1
|
||||
val payload = files.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
|
||||
val stored = storage.store(studentId, labId, seq, payload)
|
||||
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
|
||||
return toDto(entity, stored.files)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
|
||||
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
|
||||
.map { (e, fs) -> toDto(e, fs) }
|
||||
|
||||
@Transactional
|
||||
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> {
|
||||
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
|
||||
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
||||
throw ForbiddenException("Нет доступа к этой сдаче")
|
||||
}
|
||||
val dir = storage.dirFor(s.studentId, s.labId, s.seq)
|
||||
if (!Files.isDirectory(dir)) throw NotFoundException("Файлы сдачи не найдены")
|
||||
val zip = storage.zipOf(dir)
|
||||
val name = "submission-${s.labId.toString().take(8)}-try${s.seq}.zip"
|
||||
return zip to name
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun latestPerStudent(labId: UUID): List<SubmissionDto> {
|
||||
val perStudent = mutableMapOf<UUID, Submission>()
|
||||
for (s in submissions.findByLabIdOrderByCreatedAtDesc(labId)) {
|
||||
perStudent.putIfAbsent(s.studentId, s)
|
||||
}
|
||||
return perStudent.values.map { toDto(it, filesOf(it)) }
|
||||
}
|
||||
|
||||
private fun filesOf(s: Submission): List<String> =
|
||||
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
|
||||
private fun toDto(s: Submission, files: List<String>): SubmissionDto =
|
||||
SubmissionDto(
|
||||
id = s.id.toString(),
|
||||
seq = s.seq,
|
||||
studentId = s.studentId.toString(),
|
||||
labId = s.labId.toString(),
|
||||
createdAt = s.createdAt.toString(),
|
||||
files = files,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class StoredSubmission(val dir: Path, val files: List<String>)
|
||||
|
||||
@Component
|
||||
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) {
|
||||
private val rootDir: Path = Path.of(root)
|
||||
|
||||
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission {
|
||||
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize()
|
||||
require(dir.startsWith(rootDir)) { "path escape blocked" }
|
||||
Files.createDirectories(dir)
|
||||
val names = mutableListOf<String>()
|
||||
for ((rawName, bytes) in files) {
|
||||
val safe = sanitize(rawName)
|
||||
Files.write(dir.resolve(safe), bytes)
|
||||
names.add(safe)
|
||||
}
|
||||
return StoredSubmission(dir, names)
|
||||
}
|
||||
|
||||
/** Собирает zip из сохранённых файлов сдачи. */
|
||||
fun zipOf(storedDir: Path): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zos ->
|
||||
Files.list(storedDir).forEach { file ->
|
||||
ZipEntry(file.fileName.toString()).also { entry ->
|
||||
entry.time = 0
|
||||
zos.putNextEntry(entry)
|
||||
Files.copy(file, zos)
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun listFiles(storedDir: Path): List<String> =
|
||||
if (Files.isDirectory(storedDir)) {
|
||||
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
|
||||
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||
val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120)
|
||||
return if (clean.isBlank()) "file" else clean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 = "submissions")
|
||||
class Submission(
|
||||
@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(nullable = false) var seq: Int = 1,
|
||||
@Column(nullable = false) var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.ysu.lms.progress.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import java.util.UUID
|
||||
|
||||
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
||||
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
|
||||
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
|
||||
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
|
||||
}
|
||||
@@ -3,6 +3,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: progress-service
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 30MB
|
||||
max-request-size: 120MB
|
||||
datasource:
|
||||
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress}
|
||||
username: ${LMS_DB_USER:lms}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Lab submissions: files stored on disk, one row per submission attempt.
|
||||
|
||||
CREATE TABLE submissions (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
student_id uuid NOT NULL,
|
||||
lab_id uuid NOT NULL,
|
||||
seq integer NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT submissions_seq_unique UNIQUE (student_id, lab_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX submissions_lab_idx ON submissions (lab_id);
|
||||
Reference in New Issue
Block a user