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:
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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/**
|
||||
- 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/**
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class LevelAssignmentRequest(
|
||||
val studentId: String,
|
||||
@@ -120,4 +121,16 @@ data class LabStudentsResponse(
|
||||
data class GroupAssignmentResponse(
|
||||
val groupName: String,
|
||||
val students: List<EnrichedAssignment>,
|
||||
)
|
||||
|
||||
data class GradeSetRequest(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val score: BigDecimal,
|
||||
)
|
||||
|
||||
data class GradeResponse(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val score: BigDecimal,
|
||||
)
|
||||
@@ -64,4 +64,16 @@ class ProgressController(
|
||||
@RequestParam labId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue)
|
||||
|
||||
@PostMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
|
||||
|
||||
@GetMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
||||
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
|
||||
service.listGrades().filter { it.studentId == jwt.subject }
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.LabGrade
|
||||
import ru.ysu.lms.progress.domain.LabGradeKey
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevel
|
||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
||||
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ProgressService(
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val grades: LabGradeRepository,
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
@@ -169,6 +175,22 @@ class ProgressService(
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
||||
assignments.findByStudentId(studentId)
|
||||
|
||||
@Transactional
|
||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
||||
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
||||
}
|
||||
val key = LabGradeKey(UUID.fromString(request.studentId), UUID.fromString(request.labId))
|
||||
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
||||
grade.score = request.score
|
||||
grades.save(grade)
|
||||
return GradeResponse(request.studentId, request.labId, request.score)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listGrades(): List<GradeResponse> =
|
||||
grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
||||
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.ysu.lms.progress.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.IdClass
|
||||
import jakarta.persistence.Table
|
||||
import java.io.Serializable
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "lab_grades")
|
||||
@IdClass(LabGradeKey::class)
|
||||
class LabGrade(
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO,
|
||||
@Column(nullable = false) var updatedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
data class LabGradeKey(
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.ysu.lms.progress.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.progress.domain.LabGrade
|
||||
import ru.ysu.lms.progress.domain.LabGradeKey
|
||||
import java.util.UUID
|
||||
|
||||
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||
fun findAllBy(): List<LabGrade>
|
||||
fun findByStudentId(studentId: UUID): List<LabGrade>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Lab grades: one grade per student per lab.
|
||||
|
||||
CREATE TABLE lab_grades (
|
||||
student_id uuid NOT NULL,
|
||||
lab_id uuid NOT NULL,
|
||||
score numeric(4,1) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, lab_id)
|
||||
);
|
||||
Reference in New Issue
Block a user