Lab editing and group grades

- academic-service: PUT /labs/{id} (title, deadline, defense, points, status,
  description), POST/PUT/DELETE variants endpoints
- progress-service: lab_grades table, POST/GET /api/v1/grades, GET /me/grades
- gateway: routes for /api/v1/assign and /api/v1/grades
- web: lab edit page (metadata + per-variant forms + add/delete variant)
- web: group page shows grades matrix per lab with editable score inputs
  and computed average column
- web: student dashboard renders statement via markdown, shows deadline
This commit is contained in:
2026-09-11 00:03:24 +03:00
parent 0de6d3f2d8
commit 095e337136
18 changed files with 567 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import jakarta.validation.Valid
import ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@@ -30,4 +31,27 @@ class LabController(private val labService: LabService) {
fun delete(@PathVariable id: UUID) {
labService.delete(id)
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
labService.update(id, body)
@PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
labService.addVariant(id, body)
@PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun updateVariant(
@PathVariable id: UUID,
@PathVariable variantId: UUID,
@Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.updateVariant(id, variantId, body)
@DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
labService.deleteVariant(id, variantId)
}

View File

@@ -78,4 +78,20 @@ data class VariantDetail(
val number: String,
val title: String,
val statementMd: String,
)
data class LabUpdateRequest(
val title: String,
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: BigDecimal,
val status: String,
val descriptionMd: String,
)
data class VariantSaveRequest(
val labLevelId: String,
val number: String,
val title: String,
val statementMd: String,
)

View File

@@ -12,6 +12,10 @@ import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
@Service
@@ -135,4 +139,68 @@ class LabService(
}
labs.deleteById(labId)
}
@Transactional
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
lab.title = request.title.trim()
lab.deadlineAt = parseInstant(request.deadlineAt)
lab.defenseAt = parseInstant(request.defenseAt)
lab.pointsMax = request.pointsMax
lab.status = request.status
lab.descriptionMd = request.descriptionMd
labs.save(lab)
return detail(labId)
}
@Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim()
variant.title = request.title.trim()
variant.statementMd = request.statementMd
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
}
variantsRepo.save(variant)
return detail(labId)
}
@Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
val level = labLevels.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = request.number.trim(),
title = request.title.trim(),
statementMd = request.statementMd,
))
return detail(labId)
}
@Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant)
return detail(labId)
}
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) }
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
.recoverCatching { LocalDateTime.parse(v).atOffset(ZoneOffset.UTC).toInstant() }
.getOrNull()
}
}