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:
@@ -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:
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 ?: "",
|
||||||
|
|||||||
@@ -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:
|
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}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
data class StudentAssignmentView(
|
@GetMapping("/labs")
|
||||||
val labNumber: Int,
|
fun labs(session: HttpSession, model: Model): String {
|
||||||
val labTitle: String,
|
val t = token(session) ?: return "redirect:/login"
|
||||||
val variantNumber: String,
|
model.addAttribute("cards", buildCards(t))
|
||||||
val variantTitle: String,
|
return "student/labs"
|
||||||
val statementHtml: String,
|
}
|
||||||
val levelTitle: String,
|
|
||||||
val deadlineAt: String?,
|
@GetMapping("/labs/{id}")
|
||||||
val defenseAt: String?,
|
fun lab(
|
||||||
) {
|
@PathVariable id: String,
|
||||||
constructor(a: MeAssignmentDto, statementHtml: String) : this(
|
session: HttpSession,
|
||||||
labNumber = a.labNumber,
|
model: Model,
|
||||||
labTitle = a.labTitle,
|
): String {
|
||||||
variantNumber = a.variantNumber,
|
val t = token(session) ?: return "redirect:/login"
|
||||||
variantTitle = a.variantTitle,
|
return try {
|
||||||
statementHtml = statementHtml,
|
val lab = api.labDetail(t, id)
|
||||||
levelTitle = a.levelTitle,
|
val assignment = api.meAssignments(t).firstOrNull { it.labId == id }
|
||||||
deadlineAt = a.deadlineAt,
|
assignment?.let { it.statementHtml = markdown.render(it.statementMd) }
|
||||||
defenseAt = a.defenseAt,
|
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<MultipartFile>,
|
||||||
|
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<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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<div class="card-body">
|
<a class="btn btn-primary" th:href="@{/student/labs}">Лабораторные</a>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
|
||||||
<h2 class="section-title" th:text="|ЛР ${a.labNumber}. ${a.labTitle}|">ЛР</h2>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<span class="badge badge-ok">Вариант <span class="font-mono" th:text="${a.variantNumber}">—</span></span>
|
<h2 class="section-title mt-8">Мои лабораторные</h2>
|
||||||
<span th:if="${a.levelTitle}" class="badge badge-warn" th:text="${a.levelTitle}">—</span>
|
<div class="grid grid-2">
|
||||||
|
<div th:each="c : ${cards}" class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}|">ЛР</a>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm" th:text="${c.title}">—</p>
|
||||||
|
<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>
|
||||||
|
|||||||
61
web/src/main/resources/templates/student/lab.html
Normal file
61
web/src/main/resources/templates/student/lab.html
Normal 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>
|
||||||
26
web/src/main/resources/templates/student/labs.html
Normal file
26
web/src/main/resources/templates/student/labs.html
Normal 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>
|
||||||
Reference in New Issue
Block a user