From a3be71db2759902fb3fd724dafc1f2e226fa9e27 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sat, 12 Sep 2026 00:20:46 +0300 Subject: [PATCH] Phase 2+5: lab submissions and student lab flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/main/resources/application.yml | 2 +- .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 10 ++ .../ru/ysu/lms/progress/ProgressController.kt | 52 ++++++ .../ru/ysu/lms/progress/ProgressService.kt | 1 + .../ru/ysu/lms/progress/SubmissionService.kt | 85 +++++++++ .../ru/ysu/lms/progress/SubmissionStorage.kt | 62 +++++++ .../ru/ysu/lms/progress/domain/Submission.kt | 19 ++ .../lms/progress/repo/SubmissionRepository.kt | 12 ++ .../src/main/resources/application.yml | 4 + .../db/migration/V4__submissions.sql | 12 ++ .../ru/ysu/lms/web/admin/AdminController.kt | 17 ++ .../ru/ysu/lms/web/admin/AdminLabView.kt | 2 + .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 73 ++++++++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 13 ++ .../ysu/lms/web/student/StudentController.kt | 166 ++++++++++++++---- web/src/main/resources/application.yml | 4 + .../main/resources/templates/admin/lab.html | 8 +- .../resources/templates/fragments/base.html | 1 + .../templates/student/dashboard.html | 47 +++-- .../main/resources/templates/student/lab.html | 61 +++++++ .../resources/templates/student/labs.html | 26 +++ 21 files changed, 628 insertions(+), 49 deletions(-) create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Submission.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt create mode 100644 services/progress-service/src/main/resources/db/migration/V4__submissions.sql create mode 100644 web/src/main/resources/templates/student/lab.html create mode 100644 web/src/main/resources/templates/student/labs.html diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index ea7e9ff..d587354 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -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/** + - 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: endpoints: diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index eabf698..e11d2ae 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -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, ) \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index a508e34..b4d0b1f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -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 = service.listGrades().filter { it.studentId == jwt.subject } + + @PostMapping("/me/submissions/{labId}") + fun submit( + @PathVariable labId: UUID, + @RequestPart("files") files: List, + @AuthenticationPrincipal jwt: Jwt, + ): List { + 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 = submissions.mySubmissions(UUID.fromString(jwt.subject), labId) + + @GetMapping("/me/submissions/{id}/download") + fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity { + 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 = + submissions.latestPerStudent(labId) + + @GetMapping("/submissions/{id}/download") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity { + 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 = + ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}", + ) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(bytes) } \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt index 9923c93..dc02f5a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -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 ?: "", diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt new file mode 100644 index 0000000..80d3feb --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt @@ -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, + 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 = + 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 { + 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 { + val perStudent = mutableMapOf() + 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 = + storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq)) + + private fun toDto(s: Submission, files: List): SubmissionDto = + SubmissionDto( + id = s.id.toString(), + seq = s.seq, + studentId = s.studentId.toString(), + labId = s.labId.toString(), + createdAt = s.createdAt.toString(), + files = files, + ) +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt new file mode 100644 index 0000000..6f9b2fb --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt @@ -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) + +@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>): 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() + 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 = + 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 + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Submission.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Submission.kt new file mode 100644 index 0000000..ff16109 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Submission.kt @@ -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(), +) \ No newline at end of file diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt new file mode 100644 index 0000000..48b4ada --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt @@ -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 { + fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List + fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List + fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List + fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission? +} \ No newline at end of file diff --git a/services/progress-service/src/main/resources/application.yml b/services/progress-service/src/main/resources/application.yml index f118af2..c9d0dbf 100644 --- a/services/progress-service/src/main/resources/application.yml +++ b/services/progress-service/src/main/resources/application.yml @@ -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} diff --git a/services/progress-service/src/main/resources/db/migration/V4__submissions.sql b/services/progress-service/src/main/resources/db/migration/V4__submissions.sql new file mode 100644 index 0000000..4473448 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V4__submissions.sql @@ -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); \ No newline at end of file diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt index da75689..5e27444 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt @@ -251,6 +251,7 @@ class AdminController( val lab = api.labDetail(t, id) val levelAssignments = api.getLevels(t, id) val variantAssignments = api.getLabAssignments(t, id) + val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } // Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом val groupSections = api.listGroups(t).map { group -> @@ -262,6 +263,7 @@ class AdminController( val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty() val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—" val va = variantAssignments.firstOrNull { it.studentId == s.id } + val sub = submissionsByStudent[s.id] AdminLabStudent( studentId = s.id, fullName = s.fullName, @@ -276,6 +278,8 @@ class AdminController( va.manual -> "вручную" else -> "авто" }, + submissionId = sub?.id, + submissionSeq = sub?.seq, ) }, ) @@ -290,6 +294,19 @@ class AdminController( return "admin/lab" } + @GetMapping("/submissions/{id}/download") + fun downloadSubmissionAsAdmin( + @PathVariable id: String, + session: HttpSession, + ): ResponseEntity { + 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") fun assignVariant( @PathVariable labId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index e1175db..91d1cc7 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -15,6 +15,8 @@ data class AdminLabStudent( val variantId: String?, val variantNumber: String?, val source: String, + val submissionId: String? = null, + val submissionSeq: Int? = null, ) data class AdminStudentGrades( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 04530d3..76f23af 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -299,6 +299,79 @@ class ApiClient( .retrieve().body(GradeDto::class.java) } + fun myGrades(token: String): List { + val type = object : ParameterizedTypeReference>() {} + 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>): List { + val parts = LinkedMultiValueMap() + for ((name, bytes) in files) { + parts.add("files", object : ByteArrayResource(bytes) { + override fun getFilename(): String = name + }) + } + val type = object : ParameterizedTypeReference>() {} + 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 { + val type = object : ParameterizedTypeReference>() {} + 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 { + val type = object : ParameterizedTypeReference>() {} + 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): 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 = call { val spec = client.get().uri("/api/v1/content/tree") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 11698ee..01cb32e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -78,6 +78,8 @@ data class LabSummaryDto( val title: String = "", val status: String = "", val semesterTitle: String = "", + val deadlineAt: String? = null, + val defenseAt: String? = null, val pointsMax: Any? = null, val variantCount: Int = 0, ) @@ -142,6 +144,7 @@ data class LevelAssignmentResultDto( @JsonIgnoreProperties(ignoreUnknown = true) data class MeAssignmentDto( + val labId: String = "", val labNumber: Int = 0, val labTitle: String = "", val variantNumber: String = "", @@ -209,6 +212,16 @@ data class GradeDto( 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 = emptyList(), +) + data class GradeSetPayload( val studentId: String, val labId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index 4cc6cb1..15482a5 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -1,14 +1,30 @@ package ru.ysu.lms.web.student 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.ui.Model 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.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.web.DateUtils 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.SubmissionDto 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 @RequestMapping("/student") @@ -17,39 +33,127 @@ class StudentController( 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 fun dashboard(session: HttpSession, model: Model): String { - val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login" - val assignments = api.meAssignments(token).map { - it.apply { - statementHtml = markdown.render(statementMd) - deadlineMsk = ru.ysu.lms.web.DateUtils.formatMsk(deadlineAt) - defenseMsk = ru.ysu.lms.web.DateUtils.formatMsk(defenseAt) - } - } - model.addAttribute("assignments", assignments) + val t = token(session) ?: return "redirect:/login" + val cards = buildCards(t) + model.addAttribute("cards", cards) + model.addAttribute("submittedCount", cards.count { it.submissionCount > 0 }) + val scored = cards.mapNotNull { it.grade?.toBigDecimalOrNull() } + model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat()) + model.addAttribute("best", scored.maxOrNull()) + model.addAttribute("worst", scored.minOrNull()) return "student/dashboard" } -} -data class StudentAssignmentView( - val labNumber: Int, - val labTitle: String, - val variantNumber: String, - val variantTitle: String, - val statementHtml: String, - val levelTitle: String, - val deadlineAt: String?, - val defenseAt: String?, -) { - constructor(a: MeAssignmentDto, statementHtml: String) : this( - labNumber = a.labNumber, - labTitle = a.labTitle, - variantNumber = a.variantNumber, - variantTitle = a.variantTitle, - statementHtml = statementHtml, - levelTitle = a.levelTitle, - deadlineAt = a.deadlineAt, - defenseAt = a.defenseAt, - ) -} \ No newline at end of file + @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" + } + } + + @PostMapping("/labs/{id}/submit") + fun submit( + @PathVariable id: String, + @RequestParam("files") files: List, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val t = token(session) ?: return "redirect:/login" + return try { + val payload = files.filter { !it.isEmpty } + .map { (it.originalFilename ?: "file") to it.bytes } + if (payload.isEmpty()) throw ApiException(400, "Выберите файлы или архив") + api.submitLab(t, id, payload) + redirect.addFlashAttribute("notice", "Решение отправлено. Можно пересдать до истечения срока.") + "redirect:/student/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/student/labs/$id" + } + } + + @GetMapping("/submissions/{id}/download") + fun download(@PathVariable id: String, session: HttpSession): ResponseEntity { + 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 { + 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.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) + } +} diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index df7fd0c..4c9e879 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -10,6 +10,10 @@ server: spring: application: name: web + servlet: + multipart: + max-file-size: 30MB + max-request-size: 120MB thymeleaf: cache: false diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 9797a08..a83ec9b 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -98,7 +98,7 @@

Группа

- + @@ -130,8 +130,12 @@ + - +
СтудентЛогинУровеньВариантИсточник
СтудентЛогинУровеньВариантИсточникСдача
— — —↓ + не сдано
В группе нет студентов.
В группе нет студентов.
diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index d05cb7f..3501e7c 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -26,6 +26,7 @@ Группы Лабораторные + Лабораторные — diff --git a/web/src/main/resources/templates/student/dashboard.html b/web/src/main/resources/templates/student/dashboard.html index af1c7f4..3b6dde4 100644 --- a/web/src/main/resources/templates/student/dashboard.html +++ b/web/src/main/resources/templates/student/dashboard.html @@ -4,27 +4,44 @@

Здравствуйте, студент

-

Ваши лабораторные работы

-
- Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты. +
+

Сдано работ

+

0

+

Средний балл

+

—

+

—

+

Лучшая оценка

+

—

+

—

+

Слабое место

+

—

+

—

-
-
-
-

ЛР

-
- Вариант — - — + + +

Мои лабораторные

+
+
+
+

+ ЛР +

+

—

+
+ вариант + сдано (попытка 1) + не сдано + оценка
-

—

-

—

-

—

-
+
Лабораторных пока нет.
- \ No newline at end of file + diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html new file mode 100644 index 0000000..06f7d1e --- /dev/null +++ b/web/src/main/resources/templates/student/lab.html @@ -0,0 +1,61 @@ + + +Лабораторная + +
+ ← Лабораторные +
+

ЛР

+
+ Оценка: + Вариант +
+
+

Срок сдачи: МСК

+ +
+
+ +

Условие лабораторной

+
+ + +

Ваш вариант +

+
+
+
+

Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.

+
+ +

Сдача работы

+
+
+ +

zip‑архив или исходники (.c/.h). Пока не истёк срок, можно пересывать столько раз, сколько нужно — засчитывается последняя попытка.

+
+
+ +
+
Срок сдачи истёк — отправка закрыта.
+
+
+ +

История сдач

+
+ + + + + + + + + + + +
ПопыткаКогда (МСК)Файлы
1Скачать
Вы ещё ничего не сдавали.
+
+
+ + diff --git a/web/src/main/resources/templates/student/labs.html b/web/src/main/resources/templates/student/labs.html new file mode 100644 index 0000000..60e5fc7 --- /dev/null +++ b/web/src/main/resources/templates/student/labs.html @@ -0,0 +1,26 @@ + + +Лабораторные — LMS + +
+

Лабораторные работы

+
+
+
+

+ ЛР +

+
+ Вариант + сдано (попытка ) + не сдано + оценка +
+

—

+
+
+
Опубликованных лабораторных пока нет.
+
+
+ +