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:
2026-09-12 00:20:46 +03:00
parent 7818f459bd
commit a3be71db27
21 changed files with 628 additions and 49 deletions

View File

@@ -20,7 +20,7 @@ spring:
- id: progress - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: 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/** - 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/**
management: management:
endpoints: endpoints:

View File

@@ -81,6 +81,7 @@ data class VariantInfo(
) )
data class MeAssignmentResponse( data class MeAssignmentResponse(
val labId: String,
val labNumber: Int, val labNumber: Int,
val labTitle: String, val labTitle: String,
val variantNumber: String, val variantNumber: String,
@@ -134,3 +135,12 @@ data class GradeResponse(
val labId: String, val labId: String,
val score: BigDecimal, 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>,
)

View File

@@ -1,16 +1,23 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import jakarta.validation.Valid 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.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.* 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 import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
class ProgressController( class ProgressController(
private val service: ProgressService, private val service: ProgressService,
private val submissions: SubmissionService,
private val academic: AcademicClient, private val academic: AcademicClient,
) { ) {
@@ -76,4 +83,49 @@ class ProgressController(
@GetMapping("/me/grades") @GetMapping("/me/grades")
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> = fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
service.listGrades().filter { it.studentId == jwt.subject } 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)
} }

View File

@@ -202,6 +202,7 @@ class ProgressService(
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() } 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() } } val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
return MeAssignmentResponse( return MeAssignmentResponse(
labId = va.labId.toString(),
labNumber = lab.number, labNumber = lab.number,
labTitle = lab.title, labTitle = lab.title,
variantNumber = variant?.number ?: "", variantNumber = variant?.number ?: "",

View File

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

View File

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

View File

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

View File

@@ -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?
}

View File

@@ -3,6 +3,10 @@ server:
spring: spring:
application: application:
name: progress-service name: progress-service
servlet:
multipart:
max-file-size: 30MB
max-request-size: 120MB
datasource: datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress} url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress}
username: ${LMS_DB_USER:lms} username: ${LMS_DB_USER:lms}

View File

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

View File

@@ -251,6 +251,7 @@ class AdminController(
val lab = api.labDetail(t, id) val lab = api.labDetail(t, id)
val levelAssignments = api.getLevels(t, id) val levelAssignments = api.getLevels(t, id)
val variantAssignments = api.getLabAssignments(t, id) val variantAssignments = api.getLabAssignments(t, id)
val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId }
// Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом // Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом
val groupSections = api.listGroups(t).map { group -> val groupSections = api.listGroups(t).map { group ->
@@ -262,6 +263,7 @@ class AdminController(
val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty() val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty()
val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—" val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—"
val va = variantAssignments.firstOrNull { it.studentId == s.id } val va = variantAssignments.firstOrNull { it.studentId == s.id }
val sub = submissionsByStudent[s.id]
AdminLabStudent( AdminLabStudent(
studentId = s.id, studentId = s.id,
fullName = s.fullName, fullName = s.fullName,
@@ -276,6 +278,8 @@ class AdminController(
va.manual -> "вручную" va.manual -> "вручную"
else -> "авто" else -> "авто"
}, },
submissionId = sub?.id,
submissionSeq = sub?.seq,
) )
}, },
) )
@@ -290,6 +294,19 @@ class AdminController(
return "admin/lab" return "admin/lab"
} }
@GetMapping("/submissions/{id}/download")
fun downloadSubmissionAsAdmin(
@PathVariable id: String,
session: HttpSession,
): ResponseEntity<ByteArray> {
val file = api.downloadSubmissionAsAdmin(token(session), id)
val encoded = java.net.URLEncoder.encode(file.filename, java.nio.charset.StandardCharsets.UTF_8)
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file.bytes)
}
@PostMapping("/labs/{labId}/assign") @PostMapping("/labs/{labId}/assign")
fun assignVariant( fun assignVariant(
@PathVariable labId: String, @PathVariable labId: String,

View File

@@ -15,6 +15,8 @@ data class AdminLabStudent(
val variantId: String?, val variantId: String?,
val variantNumber: String?, val variantNumber: String?,
val source: String, val source: String,
val submissionId: String? = null,
val submissionSeq: Int? = null,
) )
data class AdminStudentGrades( data class AdminStudentGrades(

View File

@@ -299,6 +299,79 @@ class ApiClient(
.retrieve().body(GradeDto::class.java) .retrieve().body(GradeDto::class.java)
} }
fun myGrades(token: String): List<GradeDto> {
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
return call {
client.get().uri("/api/v1/me/grades")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun submitLab(token: String, labId: String, files: List<Pair<String, ByteArray>>): List<SubmissionDto> {
val parts = LinkedMultiValueMap<String, Any>()
for ((name, bytes) in files) {
parts.add("files", object : ByteArrayResource(bytes) {
override fun getFilename(): String = name
})
}
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
return call {
client.post().uri("/api/v1/me/submissions/{labId}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve().body(type)
} ?: emptyList()
}
fun mySubmissions(token: String, labId: String): List<SubmissionDto> {
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
return call {
client.get().uri("/api/v1/me/submissions?labId={labId}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun labSubmissions(token: String, labId: String): List<SubmissionDto> {
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
return call {
client.get().uri("/api/v1/submissions?labId={labId}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun downloadMySubmission(token: String, submissionId: String): DownloadDto {
val entity = call {
client.get().uri("/api/v1/me/submissions/{id}/download", submissionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toEntity(ByteArray::class.java)
}
return downloadOf(entity)
}
fun downloadSubmissionAsAdmin(token: String, submissionId: String): DownloadDto {
val entity = call {
client.get().uri("/api/v1/submissions/{id}/download", submissionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toEntity(ByteArray::class.java)
}
return downloadOf(entity)
}
private fun downloadOf(entity: org.springframework.http.ResponseEntity<ByteArray>): DownloadDto {
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" }
return DownloadDto(
bytes = entity.body ?: ByteArray(0),
filename = java.net.URLDecoder.decode(rawName, java.nio.charset.StandardCharsets.UTF_8),
contentType = entity.headers.contentType?.toString() ?: "application/octet-stream",
)
}
fun contentTree(token: String?): ContentTreeNodeDto = fun contentTree(token: String?): ContentTreeNodeDto =
call { call {
val spec = client.get().uri("/api/v1/content/tree") val spec = client.get().uri("/api/v1/content/tree")

View File

@@ -78,6 +78,8 @@ data class LabSummaryDto(
val title: String = "", val title: String = "",
val status: String = "", val status: String = "",
val semesterTitle: String = "", val semesterTitle: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: Any? = null, val pointsMax: Any? = null,
val variantCount: Int = 0, val variantCount: Int = 0,
) )
@@ -142,6 +144,7 @@ data class LevelAssignmentResultDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class MeAssignmentDto( data class MeAssignmentDto(
val labId: String = "",
val labNumber: Int = 0, val labNumber: Int = 0,
val labTitle: String = "", val labTitle: String = "",
val variantNumber: String = "", val variantNumber: String = "",
@@ -209,6 +212,16 @@ data class GradeDto(
val score: String = "", val score: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class SubmissionDto(
val id: String = "",
val seq: Int = 0,
val studentId: String = "",
val labId: String = "",
val createdAt: String = "",
val files: List<String> = emptyList(),
)
data class GradeSetPayload( data class GradeSetPayload(
val studentId: String, val studentId: String,
val labId: String, val labId: String,

View File

@@ -1,14 +1,30 @@
package ru.ysu.lms.web.student package ru.ysu.lms.web.student
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.GradeDto
import ru.ysu.lms.web.api.MeAssignmentDto import ru.ysu.lms.web.api.MeAssignmentDto
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import java.math.BigDecimal
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant
@Controller @Controller
@RequestMapping("/student") @RequestMapping("/student")
@@ -17,39 +33,127 @@ class StudentController(
private val markdown: MarkdownRenderer, private val markdown: MarkdownRenderer,
) { ) {
private fun token(session: HttpSession): String? =
session.getAttribute(SessionKeys.TOKEN) as? String
data class LabCard(
val id: String,
val number: Int,
val title: String,
val deadlineMsk: String?,
val defenseMsk: String?,
val variantNumber: String?,
val grade: String?,
val submissionSeq: Int?,
val submissionCount: Int,
)
@GetMapping @GetMapping
fun dashboard(session: HttpSession, model: Model): String { fun dashboard(session: HttpSession, model: Model): String {
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login" val t = token(session) ?: return "redirect:/login"
val assignments = api.meAssignments(token).map { val cards = buildCards(t)
it.apply { model.addAttribute("cards", cards)
statementHtml = markdown.render(statementMd) model.addAttribute("submittedCount", cards.count { it.submissionCount > 0 })
deadlineMsk = ru.ysu.lms.web.DateUtils.formatMsk(deadlineAt) val scored = cards.mapNotNull { it.grade?.toBigDecimalOrNull() }
defenseMsk = ru.ysu.lms.web.DateUtils.formatMsk(defenseAt) model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat())
} model.addAttribute("best", scored.maxOrNull())
} model.addAttribute("worst", scored.minOrNull())
model.addAttribute("assignments", assignments)
return "student/dashboard" return "student/dashboard"
} }
@GetMapping("/labs")
fun labs(session: HttpSession, model: Model): String {
val t = token(session) ?: return "redirect:/login"
model.addAttribute("cards", buildCards(t))
return "student/labs"
}
@GetMapping("/labs/{id}")
fun lab(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val t = token(session) ?: return "redirect:/login"
return try {
val lab = api.labDetail(t, id)
val assignment = api.meAssignments(t).firstOrNull { it.labId == id }
assignment?.let { it.statementHtml = markdown.render(it.statementMd) }
model.addAttribute("lab", lab)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute("assignment", assignment)
model.addAttribute("statementHtml", assignment?.statementHtml)
model.addAttribute("submissions", api.mySubmissions(t, id))
model.addAttribute("grade", api.myGrades(t).firstOrNull { it.labId == id }?.score)
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt))
"student/lab"
} catch (ex: ApiException) {
"redirect:/student/labs"
}
} }
data class StudentAssignmentView( @PostMapping("/labs/{id}/submit")
val labNumber: Int, fun submit(
val labTitle: String, @PathVariable id: String,
val variantNumber: String, @RequestParam("files") files: List<MultipartFile>,
val variantTitle: String, session: HttpSession,
val statementHtml: String, redirect: RedirectAttributes,
val levelTitle: String, ): String {
val deadlineAt: String?, val t = token(session) ?: return "redirect:/login"
val defenseAt: String?, return try {
) { val payload = files.filter { !it.isEmpty }
constructor(a: MeAssignmentDto, statementHtml: String) : this( .map { (it.originalFilename ?: "file") to it.bytes }
labNumber = a.labNumber, if (payload.isEmpty()) throw ApiException(400, "Выберите файлы или архив")
labTitle = a.labTitle, api.submitLab(t, id, payload)
variantNumber = a.variantNumber, redirect.addFlashAttribute("notice", "Решение отправлено. Можно пересдать до истечения срока.")
variantTitle = a.variantTitle, "redirect:/student/labs/$id"
statementHtml = statementHtml, } catch (ex: ApiException) {
levelTitle = a.levelTitle, redirect.addFlashAttribute("error", ex.message)
deadlineAt = a.deadlineAt, "redirect:/student/labs/$id"
defenseAt = a.defenseAt, }
}
@GetMapping("/submissions/{id}/download")
fun download(@PathVariable id: String, session: HttpSession): ResponseEntity<ByteArray> {
val t = token(session) ?: return ResponseEntity.status(401).build()
val file = api.downloadMySubmission(t, id)
val encoded = URLEncoder.encode(file.filename, StandardCharsets.UTF_8)
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file.bytes)
}
private fun buildCards(token: String): List<LabCard> {
val labs = api.listLabs(token).filter { it.status == "published" }
val assignments = api.meAssignments(token).associateBy { it.labId }
val grades = api.myGrades(token).associate { it.labId to it }
return labs.map { lab ->
val subs = api.mySubmissions(token, lab.id)
val assignment = assignments[lab.id]
LabCard(
id = lab.id,
number = lab.number,
title = lab.title,
deadlineMsk = DateUtils.formatMsk(lab.deadlineAt ?: assignment?.deadlineAt),
defenseMsk = DateUtils.formatMsk(lab.defenseAt ?: assignment?.defenseAt),
variantNumber = assignment?.variantNumber,
grade = grades[lab.id]?.score,
submissionSeq = subs.firstOrNull()?.seq,
submissionCount = subs.size,
) )
} }
}
private fun canSubmit(deadlineAt: String?): Boolean {
val d = deadlineAt ?: return true
val instant = runCatching { Instant.parse(d) }.getOrNull() ?: return true
return Instant.now().isBefore(instant)
}
private fun List<BigDecimal>.averageFormat(): BigDecimal {
val total = this.fold(BigDecimal.ZERO) { acc, v -> acc.add(v) }
return total.divide(BigDecimal(this.size), 2, java.math.RoundingMode.HALF_UP)
}
}

View File

@@ -10,6 +10,10 @@ server:
spring: spring:
application: application:
name: web name: web
servlet:
multipart:
max-file-size: 30MB
max-request-size: 120MB
thymeleaf: thymeleaf:
cache: false cache: false

View File

@@ -98,7 +98,7 @@
<h3 class="group-title" th:text="${g.groupName}">Группа</h3> <h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6"> <div class="card card-flush mb-6">
<table> <table>
<thead><tr><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></tr></thead>
<tbody> <tbody>
<tr th:each="s : ${g.students}"> <tr th:each="s : ${g.students}">
<td th:text="${s.fullName}">—</td> <td th:text="${s.fullName}">—</td>
@@ -130,8 +130,12 @@
</td> </td>
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td> <td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
<td><span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-warn' : 'badge-ok'" th:text="${s.source}">—</span></td> <td><span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-warn' : 'badge-ok'" th:text="${s.source}">—</span></td>
<td><a th:if="${s.submissionId}" class="badge badge-ok"
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
th:text="|попытка ${s.submissionSeq}|">↓</a>
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="6" class="empty">В группе нет студентов.</td></tr> <tr th:if="${#lists.isEmpty(g.students)}"><td colspan="7" class="empty">В группе нет студентов.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -26,6 +26,7 @@
<th:block th:if="${currentUser != null}"> <th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a> <a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a> <a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
<span class="nav-sep" th:text="${currentUser.name}">—</span> <span class="nav-sep" th:text="${currentUser.name}">—</span>
</th:block> </th:block>
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button> <button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>

View File

@@ -4,26 +4,43 @@
<body> <body>
<div id="content"> <div id="content">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1> <h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
<p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted"> <div class="grid grid-stats">
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты. <div class="card card-body"><p class="stat-title">Сдано работ</p>
<p class="stat-value" th:text="${submittedCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Средний балл</p>
<p class="stat-value" th:if="${average != null}" th:text="${average}">—</p>
<p class="stat-value text-muted" th:if="${average == null}">—</p></div>
<div class="card card-body"><p class="stat-title">Лучшая оценка</p>
<p class="stat-value" th:if="${best != null}" th:text="${best.toPlainString()}">—</p>
<p class="stat-value text-muted" th:if="${best == null}">—</p></div>
<div class="card card-body"><p class="stat-title">Слабое место</p>
<p class="stat-value" th:if="${worst != null}" th:text="${worst.toPlainString()}">—</p>
<p class="stat-value text-muted" th:if="${worst == null}">—</p></div>
</div> </div>
<div th:each="a : ${assignments}" class="card mb-6"> <div class="flex flex-wrap gap-2 mt-8">
<a class="btn btn-primary" th:href="@{/student/labs}">Лабораторные</a>
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div>
<h2 class="section-title mt-8">Мои лабораторные</h2>
<div class="grid grid-2">
<div th:each="c : ${cards}" class="card">
<div class="card-body"> <div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4"> <h3 class="section-title">
<h2 class="section-title" th:text="|ЛР ${a.labNumber}. ${a.labTitle}|">ЛР</h2> <a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}|">ЛР</a>
<div class="flex gap-2 items-center"> </h3>
<span class="badge badge-ok">Вариант <span class="font-mono" th:text="${a.variantNumber}">—</span></span> <p class="text-sm" th:text="${c.title}">—</p>
<span th:if="${a.levelTitle}" class="badge badge-warn" th:text="${a.levelTitle}">—</span> <div class="flex flex-wrap gap-2 mt-2 text-sm">
<span th:if="${c.variantNumber}" class="badge badge-warn">вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}">1</span>)</span>
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
</div> </div>
</div> </div>
<p class="text-sm text-muted" th:if="${a.variantTitle}" th:text="${a.variantTitle}">—</p>
<p class="text-sm text-muted" th:if="${a.deadlineMsk}" th:text="|Срок сдачи: ${a.deadlineMsk} (МСК)|">—</p>
<p class="text-sm text-muted" th:if="${a.defenseMsk}" th:text="|Защита: ${a.defenseMsk} (МСК)|">—</p>
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
</div> </div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Лабораторных пока нет.</div>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="|ЛР ${lab.number} — LMS|">Лабораторная</title></head>
<body>
<div id="content">
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2">
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<div class="flex gap-2 text-sm">
<span th:if="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
</div>
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<h2 class="section-title">Условие лабораторной</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<th:block th:if="${assignment != null}">
<h2 class="section-title">Ваш вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span>
<span class="text-muted text-sm" th:if="${assignment.levelTitle}" th:text="|(${assignment.levelTitle})|"></span></h2>
<div class="card card-body md-body mb-6" th:utext="${statementHtml}"></div>
</th:block>
<div th:if="${assignment == null}" class="card card-body mb-6">
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
</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}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Пока не истёк срок, можно пересывать столько раз, сколько нужно — засчитывается последняя попытка.</p>
<div><input class="input" name="files" type="file" multiple required></div>
<div th:if="${canSubmit}">
<button class="btn btn-primary" type="submit">Отправить решение</button>
</div>
<div th:unless="${canSubmit}" class="alert alert-warning">Срок сдачи истёк — отправка закрыта.</div>
</form>
</div>
<h3 class="group-title">История сдач</h3>
<div class="card card-flush">
<table>
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
<tbody>
<tr th:each="s : ${submissions}">
<td th:text="${s.seq}">1</td>
<td class="text-muted" th:text="${#strings.replace(s.createdAt, 'T', ' ')}"></td>
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
</tr>
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Вы ещё ничего не сдавали.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Лабораторные — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Лабораторные работы</h1>
<div class="grid grid-2">
<div th:each="c : ${cards}" class="card">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}. ${c.title}|">ЛР</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
<span th:if="${c.variantNumber}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}"></span>)</span>
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
</div>
<p class="text-sm text-muted mt-2" th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</p>
</div>
</div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Опубликованных лабораторных пока нет.</div>
</div>
</div>
</body>
</html>