feat: add course attestations and average grade column
- store attestation columns and per-student results in progress-service - add teacher UI for intermediate/pass/exam columns with semester and missed hours - show average score per student on the group page - show attestation results on the student dashboard
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
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.DeleteMapping
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class AttestationController(
|
||||
private val service: AttestationService,
|
||||
) {
|
||||
@GetMapping("/attestations")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun list(
|
||||
@RequestParam groupId: UUID,
|
||||
): List<AttestationResponse> = service.list(groupId)
|
||||
|
||||
@PostMapping("/attestations")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun create(
|
||||
@RequestBody body: AttestationCreateRequest,
|
||||
): AttestationResponse = service.create(body)
|
||||
|
||||
@DeleteMapping("/attestations/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
): Map<String, Boolean> {
|
||||
service.delete(id)
|
||||
return mapOf("ok" to true)
|
||||
}
|
||||
|
||||
@PostMapping("/attestations/{id}/results")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setResult(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: AttestationResultRequest,
|
||||
): AttestationResponse = service.setResult(id, body)
|
||||
|
||||
/** Результаты аттестаций текущего студента по его группе. */
|
||||
@GetMapping("/me/attestations")
|
||||
fun myAttestations(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@RequestParam(required = false) groupId: UUID?,
|
||||
): List<AttestationResponse> {
|
||||
if (groupId == null) return emptyList()
|
||||
return service.listForStudent(groupId, UUID.fromString(jwt.subject))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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.common.NotFoundException
|
||||
import ru.ysu.lms.progress.domain.Attestation
|
||||
import ru.ysu.lms.progress.domain.AttestationResult
|
||||
import ru.ysu.lms.progress.domain.AttestationResultKey
|
||||
import ru.ysu.lms.progress.repo.AttestationRepository
|
||||
import ru.ysu.lms.progress.repo.AttestationResultRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class AttestationService(
|
||||
private val attestations: AttestationRepository,
|
||||
private val results: AttestationResultRepository,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun list(groupId: UUID): List<AttestationResponse> {
|
||||
val columns = attestations.findByGroupIdOrderByCreatedAtAscIdAsc(groupId)
|
||||
if (columns.isEmpty()) return emptyList()
|
||||
val byAttestation = results.findByAttestationIdIn(columns.map { it.id }).groupBy { it.attestationId }
|
||||
return columns.map { toResponse(it, byAttestation[it.id].orEmpty()) }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listForStudent(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): List<AttestationResponse> =
|
||||
list(groupId).map { column ->
|
||||
column.copy(results = column.results.filter { it.studentId == studentId.toString() })
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: AttestationCreateRequest): AttestationResponse {
|
||||
val kind = normalizeKind(request.kind)
|
||||
val semester = request.semester.trim()
|
||||
if (semester.isEmpty()) throw BadRequestException("Укажите семестр")
|
||||
if (semester.length > MAX_SEMESTER_LENGTH) throw BadRequestException("Слишком длинное название семестра")
|
||||
val column =
|
||||
attestations.save(
|
||||
Attestation(
|
||||
groupId = UUID.fromString(request.groupId),
|
||||
kind = kind,
|
||||
semester = semester,
|
||||
),
|
||||
)
|
||||
return toResponse(column, emptyList())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(attestationId: UUID) {
|
||||
val column =
|
||||
attestations
|
||||
.findById(attestationId)
|
||||
.orElseThrow { NotFoundException("Аттестация не найдена") }
|
||||
attestations.delete(column)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setResult(
|
||||
attestationId: UUID,
|
||||
request: AttestationResultRequest,
|
||||
): AttestationResponse {
|
||||
val column =
|
||||
attestations
|
||||
.findById(attestationId)
|
||||
.orElseThrow { NotFoundException("Аттестация не найдена") }
|
||||
val studentId = UUID.fromString(request.studentId)
|
||||
val key = AttestationResultKey(attestationId, studentId)
|
||||
val value = request.value.trim().lowercase()
|
||||
val existing = results.findById(key).orElse(null)
|
||||
|
||||
if (value.isEmpty()) {
|
||||
existing?.let { results.delete(it) }
|
||||
return toResponse(column, results.findByAttestationId(attestationId))
|
||||
}
|
||||
|
||||
val allowed = VALUES[column.kind] ?: emptySet()
|
||||
if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации")
|
||||
val missedHours = if (column.kind == KIND_INTERMEDIATE) request.missedHours else null
|
||||
if (missedHours != null && missedHours < 0) {
|
||||
throw BadRequestException("Число пропущенных часов не может быть отрицательным")
|
||||
}
|
||||
|
||||
val entity =
|
||||
(existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply {
|
||||
this.value = value
|
||||
this.missedHours = missedHours
|
||||
this.updatedAt = Instant.now()
|
||||
}
|
||||
results.save(entity)
|
||||
return toResponse(column, results.findByAttestationId(attestationId))
|
||||
}
|
||||
|
||||
private fun normalizeKind(raw: String): String {
|
||||
val kind = raw.trim().lowercase()
|
||||
if (kind !in KINDS) throw BadRequestException("Неизвестный тип аттестации")
|
||||
return kind
|
||||
}
|
||||
|
||||
private fun toResponse(
|
||||
column: Attestation,
|
||||
rows: List<AttestationResult>,
|
||||
): AttestationResponse =
|
||||
AttestationResponse(
|
||||
id = column.id.toString(),
|
||||
groupId = column.groupId.toString(),
|
||||
kind = column.kind,
|
||||
semester = column.semester,
|
||||
results =
|
||||
rows
|
||||
.sortedBy { it.studentId }
|
||||
.map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) },
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KIND_INTERMEDIATE = "intermediate"
|
||||
const val KIND_PASS = "pass"
|
||||
const val KIND_EXAM = "exam"
|
||||
private const val MAX_SEMESTER_LENGTH = 100
|
||||
|
||||
private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM)
|
||||
private val VALUES =
|
||||
mapOf(
|
||||
KIND_INTERMEDIATE to setOf("excellent", "satisfactory", "unsatisfactory", "not_attested"),
|
||||
KIND_PASS to setOf("passed", "failed", "absent"),
|
||||
KIND_EXAM to setOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,34 @@ data class GradeResponse(
|
||||
val generalComment: String = "",
|
||||
)
|
||||
|
||||
data class AttestationCreateRequest(
|
||||
val groupId: String,
|
||||
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
|
||||
val kind: String,
|
||||
val semester: String,
|
||||
)
|
||||
|
||||
data class AttestationResultRequest(
|
||||
val studentId: String,
|
||||
/** Пустое значение очищает результат. */
|
||||
val value: String = "",
|
||||
val missedHours: Int? = null,
|
||||
)
|
||||
|
||||
data class AttestationResultResponse(
|
||||
val studentId: String,
|
||||
val value: String,
|
||||
val missedHours: Int? = null,
|
||||
)
|
||||
|
||||
data class AttestationResponse(
|
||||
val id: String,
|
||||
val groupId: String,
|
||||
val kind: String,
|
||||
val semester: String,
|
||||
val results: List<AttestationResultResponse> = emptyList(),
|
||||
)
|
||||
|
||||
data class SubmissionDto(
|
||||
val id: String,
|
||||
val seq: Int,
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.repo.AttestationResultRepository
|
||||
import ru.ysu.lms.progress.repo.DefenseSessionRepository
|
||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
||||
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
|
||||
@@ -21,6 +22,7 @@ class StudentPurgeService(
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val publicationsSt: GradePublicationStudentRepository,
|
||||
private val attestationResults: AttestationResultRepository,
|
||||
private val storage: SubmissionStorage,
|
||||
) {
|
||||
/** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */
|
||||
@@ -33,6 +35,7 @@ class StudentPurgeService(
|
||||
assignments.findByStudentId(studentId).also { assignments.deleteAll(it) }
|
||||
levels.findByStudentId(studentId).also { levels.deleteAll(it) }
|
||||
publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) }
|
||||
attestationResults.findByStudentId(studentId).also { attestationResults.deleteAll(it) }
|
||||
}
|
||||
|
||||
fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 ru.ysu.lms.common.Ids
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "attestations")
|
||||
class Attestation(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false, length = 16) var kind: String = "intermediate",
|
||||
@Column(nullable = false, length = 100) var semester: String = "",
|
||||
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "attestation_results")
|
||||
@IdClass(AttestationResultKey::class)
|
||||
class AttestationResult(
|
||||
@Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
|
||||
@Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false, length = 32) var value: String = "",
|
||||
@Column(name = "missed_hours") var missedHours: Int? = null,
|
||||
@Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
data class AttestationResultKey(
|
||||
val attestationId: UUID = UUID(0, 0),
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
) : Serializable {
|
||||
companion object {
|
||||
private const val serialVersionUID: Long = 1L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.ysu.lms.progress.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.progress.domain.Attestation
|
||||
import ru.ysu.lms.progress.domain.AttestationResult
|
||||
import ru.ysu.lms.progress.domain.AttestationResultKey
|
||||
import java.util.UUID
|
||||
|
||||
interface AttestationRepository : JpaRepository<Attestation, UUID> {
|
||||
fun findByGroupIdOrderByCreatedAtAscIdAsc(groupId: UUID): List<Attestation>
|
||||
}
|
||||
|
||||
interface AttestationResultRepository : JpaRepository<AttestationResult, AttestationResultKey> {
|
||||
fun findByAttestationId(attestationId: UUID): List<AttestationResult>
|
||||
|
||||
fun findByAttestationIdIn(attestationIds: Collection<UUID>): List<AttestationResult>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<AttestationResult>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Аттестации группы: промежуточная аттестация, зачёт, экзамен.
|
||||
CREATE TABLE attestations (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
group_id uuid NOT NULL,
|
||||
kind varchar(16) NOT NULL,
|
||||
semester varchar(100) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT attestations_kind_check CHECK (kind IN ('intermediate', 'pass', 'exam'))
|
||||
);
|
||||
|
||||
CREATE INDEX attestations_group_idx ON attestations (group_id, created_at);
|
||||
|
||||
CREATE TABLE attestation_results (
|
||||
attestation_id uuid NOT NULL REFERENCES attestations (id) ON DELETE CASCADE,
|
||||
student_id uuid NOT NULL,
|
||||
value varchar(32) NOT NULL,
|
||||
missed_hours integer,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (attestation_id, student_id),
|
||||
CONSTRAINT attestation_results_value_check CHECK (value <> ''),
|
||||
CONSTRAINT attestation_results_hours_check CHECK (missed_hours IS NULL OR missed_hours >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX attestation_results_student_idx ON attestation_results (student_id);
|
||||
Reference in New Issue
Block a user