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:
2026-10-26 16:33:59 +03:00
parent e22443b08e
commit f7a1830548
24 changed files with 914 additions and 25 deletions

View File

@@ -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/defenses,/api/v1/defenses/**,/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/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/** - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/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/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**,/api/v1/attestations,/api/v1/attestations/**
management: management:
endpoints: endpoints:

View File

@@ -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))
}
}

View File

@@ -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"),
)
}
}

View File

@@ -163,6 +163,34 @@ data class GradeResponse(
val generalComment: String = "", 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( data class SubmissionDto(
val id: String, val id: String,
val seq: Int, val seq: Int,

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.DefenseSessionRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
@@ -21,6 +22,7 @@ class StudentPurgeService(
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val publicationsSt: GradePublicationStudentRepository, private val publicationsSt: GradePublicationStudentRepository,
private val attestationResults: AttestationResultRepository,
private val storage: SubmissionStorage, private val storage: SubmissionStorage,
) { ) {
/** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */ /** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */
@@ -33,6 +35,7 @@ class StudentPurgeService(
assignments.findByStudentId(studentId).also { assignments.deleteAll(it) } assignments.findByStudentId(studentId).also { assignments.deleteAll(it) }
levels.findByStudentId(studentId).also { levels.deleteAll(it) } levels.findByStudentId(studentId).also { levels.deleteAll(it) }
publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) } publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) }
attestationResults.findByStudentId(studentId).also { attestationResults.deleteAll(it) }
} }
fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId) fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId)

View File

@@ -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
}
}

View File

@@ -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>
}

View File

@@ -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);

View File

@@ -0,0 +1,55 @@
package ru.ysu.lms.web
/** Подписи типов и оценок аттестации для интерфейса. */
object AttestationPresentation {
private val KIND_LABELS =
mapOf(
"intermediate" to "Промежуточная аттестация",
"pass" to "Зачёт",
"exam" to "Экзамен",
)
private val VALUE_LABELS =
mapOf(
"intermediate" to
mapOf(
"excellent" to "отл",
"satisfactory" to "атт",
"unsatisfactory" to "неуд",
"not_attested" to "не атт",
),
"pass" to
mapOf(
"passed" to "зачтено",
"failed" to "не зачтено",
"absent" to "неявка",
),
"exam" to
mapOf(
"excellent" to "отлично",
"good" to "хорошо",
"satisfactory" to "удовлетворительно",
"unsatisfactory" to "неудовлетворительно",
"absent" to "неявка",
),
)
private val POSITIVE_VALUES =
mapOf(
"intermediate" to setOf("excellent", "satisfactory"),
"pass" to setOf("passed"),
"exam" to setOf("excellent", "good", "satisfactory"),
)
fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind
fun valueLabel(
kind: String,
value: String,
): String = VALUE_LABELS[kind]?.get(value) ?: value
fun isPositive(
kind: String,
value: String,
): Boolean? = if (value.isBlank()) null else POSITIVE_VALUES[kind]?.contains(value) == true
}

View File

@@ -0,0 +1,96 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ModelAttribute
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.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AttestationCreatePayload
import ru.ysu.lms.web.api.AttestationResultPayload
import ru.ysu.lms.web.api.createAttestation
import ru.ysu.lms.web.api.deleteAttestation
import ru.ysu.lms.web.api.setAttestationResult
import ru.ysu.lms.web.session.SessionKeys
@Controller
@RequestMapping("/admin")
class AdminAttestationController(
private val api: ApiClient,
) {
@PostMapping("/groups/{id}/attestations")
fun createAttestation(
@PathVariable id: String,
@ModelAttribute form: CreateAttestationForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val semester = form.semester.trim()
if (semester.isBlank()) throw ApiException(400, "Укажите семестр")
api.createAttestation(
token(session),
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
)
redirect.addFlashAttribute("notice", "Столбец аттестации добавлен")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@PostMapping("/groups/{id}/attestations/{attestationId}/delete")
fun deleteAttestation(
@PathVariable id: String,
@PathVariable attestationId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.deleteAttestation(token(session), attestationId)
redirect.addFlashAttribute("notice", "Столбец аттестации удалён")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@PostMapping("/groups/{id}/attestation-results")
fun setAttestationResult(
@PathVariable id: String,
@ModelAttribute form: SetAttestationResultForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.setAttestationResult(
token(session),
form.attestationId,
AttestationResultPayload(
studentId = form.studentId,
value = form.value,
missedHours = parseMissedHours(form.missedHours),
),
)
redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
private fun parseMissedHours(raw: String?): Int? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым")
if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным")
return value
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

@@ -85,6 +85,18 @@ data class SetGradeForm(
val generalComment: String = "", val generalComment: String = "",
) )
data class CreateAttestationForm(
val kind: String = "",
val semester: String = "",
)
data class SetAttestationResultForm(
val attestationId: String = "",
val studentId: String = "",
val value: String = "",
val missedHours: String? = null,
)
data class SubmissionDownloadForm( data class SubmissionDownloadForm(
val group: String? = null, val group: String? = null,
val no: String? = null, val no: String? = null,

View File

@@ -38,6 +38,7 @@ import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.labSubmissions import ru.ysu.lms.web.api.labSubmissions
import ru.ysu.lms.web.api.listAttestations
import ru.ysu.lms.web.api.listGrades import ru.ysu.lms.web.api.listGrades
import ru.ysu.lms.web.api.listGroups import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
@@ -46,6 +47,8 @@ import ru.ysu.lms.web.api.setGrade
import ru.ysu.lms.web.api.setPublication import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.api.suggestComments import ru.ysu.lms.web.api.suggestComments
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -137,7 +140,11 @@ class AdminGroupController(
model: Model, model: Model,
): String { ): String {
val t = token(session) val t = token(session)
val (group, labs) = loadGroupAndLabs(t, id) val allLabs = api.listLabs(t)
val group = api.groupDetail(t, id)
val labs = allLabs.filter { it.status == "published" }
val attestations = GroupAttestationViews.columns(api.listAttestations(t, id))
val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations)
val grades = api.listGrades(t) val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -166,19 +173,29 @@ class AdminGroupController(
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
val rows = val rows =
buildRows( GroupAttestationViews.attach(
group.students, buildRows(
labs, group.students,
gradeBy, labs,
generalCommentBy, gradeBy,
commentsBy, generalCommentBy,
publishedByLab, commentsBy,
assignmentsBy, publishedByLab,
submissionsBy, assignmentsBy,
defensesBy, submissionsBy,
defensesBy,
),
attestations,
) )
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy))) fillGroupModel(
model,
groupViewModel(
t,
id,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters),
),
)
return "admin/group" return "admin/group"
} }
@@ -222,6 +239,7 @@ class AdminGroupController(
defenseAssignmentMd = def?.assignmentMd, defenseAssignmentMd = def?.assignmentMd,
) )
} }
val averageScore = averageScore(cells)
GroupStudentRow( GroupStudentRow(
studentId = s.id, studentId = s.id,
fullName = s.fullName, fullName = s.fullName,
@@ -239,13 +257,19 @@ class AdminGroupController(
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
}, },
), ),
averageScore = averageScore,
) )
} }
private fun loadGroupAndLabs( private fun averageScore(cells: List<GroupLabCell>): String? {
token: String, val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
id: String, if (scores.isEmpty()) return null
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" } return scores
.fold(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
}
private data class GroupRenderData( private data class GroupRenderData(
val group: GroupDetailDto, val group: GroupDetailDto,
@@ -253,6 +277,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>, val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun groupViewModel( private fun groupViewModel(
@@ -269,7 +295,16 @@ class AdminGroupController(
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0) lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
} }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return GroupViewModel(data.group, data.labs, candidates, data.rows, data.labPublication, submissionCounts) return GroupViewModel(
data.group,
data.labs,
candidates,
data.rows,
data.labPublication,
submissionCounts,
data.attestations,
data.attestationSemesters,
)
} }
private data class GroupViewModel( private data class GroupViewModel(
@@ -279,6 +314,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>, val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun fillGroupModel( private fun fillGroupModel(
@@ -291,6 +328,8 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("attestationSemesters", view.attestationSemesters)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }

View File

@@ -49,6 +49,24 @@ data class GroupStudentRow(
val cells: List<GroupLabCell>, val cells: List<GroupLabCell>,
val variantsSummary: String, val variantsSummary: String,
val commentsJson: String, val commentsJson: String,
val averageScore: String? = null,
val attestations: Map<String, GroupAttestationResult> = emptyMap(),
)
/** Результат студента по одному столбцу аттестации. */
data class GroupAttestationResult(
val value: String = "",
val label: String = "",
val missedHours: Int? = null,
)
/** Столбец аттестации на странице группы. */
data class GroupAttestation(
val id: String = "",
val kind: String = "",
val kindLabel: String = "",
val semester: String = "",
val results: Map<String, GroupAttestationResult> = emptyMap(),
) )
/** Ряд блока защит (одна сессия на строку). */ /** Ряд блока защит (одна сессия на строку). */

View File

@@ -0,0 +1,49 @@
package ru.ysu.lms.web.admin
import ru.ysu.lms.web.AttestationPresentation
import ru.ysu.lms.web.api.AttestationDto
import ru.ysu.lms.web.api.LabSummaryDto
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */
object GroupAttestationViews {
fun columns(attestations: List<AttestationDto>): List<GroupAttestation> =
attestations.map { column ->
GroupAttestation(
id = column.id,
kind = column.kind,
kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester,
results =
column.results.associate { row ->
row.studentId to
GroupAttestationResult(
value = row.value,
label = AttestationPresentation.valueLabel(column.kind, row.value),
missedHours = row.missedHours,
)
},
)
}
fun semesters(
labs: List<LabSummaryDto>,
attestations: List<GroupAttestation>,
): List<String> =
(labs.map { it.semesterTitle } + attestations.map { it.semester })
.filter { it.isNotBlank() && it != "—" }
.distinct()
.sorted()
fun attach(
rows: List<GroupStudentRow>,
columns: List<GroupAttestation>,
): List<GroupStudentRow> =
rows.map { row ->
val byColumn = LinkedHashMap<String, GroupAttestationResult>()
for (column in columns) {
val result = column.results[row.studentId] ?: continue
byColumn[column.id] = result
}
row.copy(attestations = byColumn)
}
}

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
fun ApiClient.listAttestations(
token: String,
groupId: String,
): List<AttestationDto> {
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
return call {
client
.get()
.uri("/api/v1/attestations?groupId={gid}", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.createAttestation(
token: String,
payload: AttestationCreatePayload,
): AttestationDto =
call {
client
.post()
.uri("/api/v1/attestations")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(AttestationDto::class.java)
}
fun ApiClient.deleteAttestation(
token: String,
attestationId: String,
) {
call {
client
.delete()
.uri("/api/v1/attestations/{id}", attestationId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.setAttestationResult(
token: String,
attestationId: String,
payload: AttestationResultPayload,
): AttestationDto =
call {
client
.post()
.uri("/api/v1/attestations/{id}/results", attestationId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(AttestationDto::class.java)
}
fun ApiClient.myAttestations(
token: String,
groupId: String,
): List<AttestationDto> {
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
return call {
client
.get()
.uri("/api/v1/me/attestations?groupId={gid}", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}

View File

@@ -264,6 +264,34 @@ data class GradeDto(
val generalComment: String = "", val generalComment: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class AttestationResultDto(
val studentId: String = "",
val value: String = "",
val missedHours: Int? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AttestationDto(
val id: String = "",
val groupId: String = "",
val kind: String = "",
val semester: String = "",
val results: List<AttestationResultDto> = emptyList(),
)
data class AttestationCreatePayload(
val groupId: String,
val kind: String,
val semester: String,
)
data class AttestationResultPayload(
val studentId: String,
val value: String,
val missedHours: Int? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto( data class CommentDto(
val text: String = "", val text: String = "",

View File

@@ -54,7 +54,10 @@ data class FormFeedback(
Rule(Regex("(?iu)номер лабораторной"), listOf("number")), Rule(Regex("(?iu)номер лабораторной"), listOf("number")),
Rule(Regex("(?iu)номер варианта"), listOf("number")), Rule(Regex("(?iu)номер варианта"), listOf("number")),
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")), Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
Rule(Regex("(?iu)оценк"), listOf("score")), Rule(Regex("(?iu)оценк"), listOf("score", "value")),
Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")),
Rule(Regex("(?iu)семестр"), listOf("semester")),
Rule(Regex("(?iu)тип аттестации"), listOf("kind")),
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")), Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),
Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")), Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")),
Rule( Rule(

View File

@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes 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.AttestationPresentation
import ru.ysu.lms.web.DateUtils 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.ApiException
@@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.finishDefense
import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.meAssignments import ru.ysu.lms.web.api.meAssignments
import ru.ysu.lms.web.api.myAttestations
import ru.ysu.lms.web.api.myDefense import ru.ysu.lms.web.api.myDefense
import ru.ysu.lms.web.api.myDefenseStatus import ru.ysu.lms.web.api.myDefenseStatus
import ru.ysu.lms.web.api.myGrades import ru.ysu.lms.web.api.myGrades
@@ -59,6 +61,14 @@ class StudentController(
val submissionCount: Int, val submissionCount: Int,
) )
data class StudentAttestationView(
val kindLabel: String,
val semester: String,
val valueLabel: String,
val positive: Boolean?,
val missedHours: Int?,
)
@GetMapping @GetMapping
fun dashboard( fun dashboard(
session: HttpSession, session: HttpSession,
@@ -72,6 +82,26 @@ class StudentController(
model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat()) model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat())
model.addAttribute("best", scored.maxOrNull()) model.addAttribute("best", scored.maxOrNull())
model.addAttribute("worst", scored.minOrNull()) model.addAttribute("worst", scored.minOrNull())
val groupId = studentGroupId(t)
val attestations =
if (groupId == null) {
emptyList()
} else {
runCatching { api.myAttestations(t, groupId) }.getOrDefault(emptyList())
}
model.addAttribute(
"attestations",
attestations.map { column ->
val result = column.results.firstOrNull()
StudentAttestationView(
kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester,
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
missedHours = result?.missedHours,
)
},
)
return "student/dashboard" return "student/dashboard"
} }

View File

@@ -557,6 +557,11 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; }
.matrix tbody tr:last-child td { border-bottom: none; } .matrix tbody tr:last-child td { border-bottom: none; }
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); }
.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); }
.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); font-weight: 600; }
.attestation-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; text-align: left; }
.attestation-kind { font-weight: 600; color: var(--c-text); }
.matrix td.student-cell { min-width: 12rem; } .matrix td.student-cell { min-width: 12rem; }
.student-name { font-weight: 600; font-size: 0.84375rem; } .student-name { font-weight: 600; font-size: 0.84375rem; }
.student-cell .font-mono { margin-top: 0.125rem; } .student-cell .font-mono { margin-top: 0.125rem; }

View File

@@ -32,6 +32,59 @@ function openGradeEditor(btn) {
if (d && d.showModal) d.showModal(); if (d && d.showModal) d.showModal();
} }
/* ---------- Аттестация: выбор результата ---------- */
var ATTESTATION_OPTIONS = {
intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']],
pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']],
exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']]
};
function openAttestation(btn) {
var form = $id('attestation-form');
if (!form) return;
var kind = btn.getAttribute('data-attestation-kind') || 'intermediate';
var student = btn.getAttribute('data-student') || '';
var studentName = btn.getAttribute('data-student-name') || '';
var label = btn.getAttribute('data-attestation-label') || 'Аттестация';
var semester = btn.getAttribute('data-semester') || '';
var value = btn.getAttribute('data-attestation-value') || '';
$id('attestation-id').value = btn.getAttribute('data-attestation') || '';
$id('attestation-student').value = student;
$id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : '');
var select = $id('attestation-value');
select.innerHTML = '';
var empty = document.createElement('option');
empty.value = '';
empty.textContent = '— не выставлено —';
select.appendChild(empty);
(ATTESTATION_OPTIONS[kind] || []).forEach(function (item) {
var option = document.createElement('option');
option.value = item[0];
option.textContent = item[1];
select.appendChild(option);
});
select.value = value;
var hours = $id('attestation-hours');
var hoursWrap = $id('attestation-hours-wrap');
var missed = btn.getAttribute('data-missed-hours');
if (hours) hours.value = missed && missed !== 'null' ? missed : '';
if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate';
if (kind !== 'intermediate' && hours) hours.value = '';
var title = $id('attestation-title');
if (title) title.textContent = label + ' — ' + studentName;
var dialog = $id('modal-attestation');
if (dialog && dialog.showModal) dialog.showModal();
}
window.openAttestation = openAttestation;
function csRow(text, tone) { function csRow(text, tone) {
var box = $id('cs-rows'); var box = $id('cs-rows');
var row = document.createElement('div'); var row = document.createElement('div');

View File

@@ -270,6 +270,7 @@
var kind = action.getAttribute('data-action'); var kind = action.getAttribute('data-action');
var fn = { var fn = {
grade: window.openGradeEditor, grade: window.openGradeEditor,
attestation: window.openAttestation,
variant: window.openVariant, variant: window.openVariant,
defense: window.openDefense, defense: window.openDefense,
assign: window.openLabAssign, assign: window.openLabAssign,

View File

@@ -17,6 +17,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button> <button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div> </div>
</div> </div>
@@ -30,10 +31,10 @@
</select> </select>
</div> </div>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div> <div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}"> <input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div> <div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x"> <div class="scroll-x">
<table class="matrix tablekit" id="group-table"> <table class="matrix tablekit" id="group-table">
<thead> <thead>
@@ -70,6 +71,23 @@
</div> </div>
</div> </div>
</th> </th>
<th class="text-center" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
<div class="attestation-head">
<div class="lab-head-actions">
<form class="inline-form"
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
method="post"
data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm p-2" type="submit"
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button>
</form>
</div>
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
<span class="lab-col-title" th:text="${att.semester}">—</span>
</div>
</th>
<th data-nosort>Пароль</th> <th data-nosort>Пароль</th>
<th class="text-right" data-nosort>Действия</th> <th class="text-right" data-nosort>Действия</th>
</tr> </tr>
@@ -120,6 +138,19 @@
</div> </div>
</div> </div>
</td> </td>
<td class="text-center font-mono average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="text-center attestation-cell">
<button class="score-link" type="button"
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-attestation=${att.id},data-attestation-kind=${att.kind},data-attestation-value=${row.attestations[att.id]?.value},data-missed-hours=${row.attestations[att.id]?.missedHours},data-attestation-label=${att.kindLabel},data-semester=${att.semester}"
data-action="attestation"
th:title="${att.kindLabel} + ' — ' + ${att.semester}">
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span>
<span th:if="${row.attestations[att.id]?.missedHours != null}"
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span>
</button>
</td>
<td class="text-center"> <td class="text-center">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span> <span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span> <span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
@@ -139,7 +170,7 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr> <tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 4}">В группе нет студентов.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -173,6 +204,64 @@
</div> </div>
</dialog> </dialog>
<!-- Диалог: добавление столбца аттестации -->
<dialog id="modal-add-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая аттестация</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" method="post" th:action="@{/admin/groups/{id}/attestations(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="attestation-kind">Форма аттестации</label>
<select class="input" id="attestation-kind" name="kind" required>
<option value="intermediate">Промежуточная аттестация</option>
<option value="pass">Зачёт</option>
<option value="exam">Экзамен</option>
</select></div>
<div><label class="label" for="attestation-semester">Семестр</label>
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters"
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required>
<datalist id="attestation-semesters">
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option>
</datalist>
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p>
</div>
<button class="btn btn-primary" type="submit">Добавить столбец</button>
</form>
</div>
</dialog>
<!-- Диалог: результат аттестации студента -->
<dialog id="modal-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title" id="attestation-title">Результат аттестации</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="attestation-form" method="post"
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="attestationId" id="attestation-id">
<input type="hidden" name="studentId" id="attestation-student">
<div><label class="label">Студент</label>
<input class="input" id="attestation-who" readonly></div>
<div><label class="label" for="attestation-value">Результат</label>
<select class="input" id="attestation-value" name="value">
<option value="">— не выставлено —</option>
</select>
<p class="hint">Оставьте пустым, чтобы убрать результат.</p>
</div>
<div id="attestation-hours-wrap"><label class="label" for="attestation-hours">Пропущено академических часов</label>
<input class="input" id="attestation-hours" name="missedHours" type="number" min="0" step="1"
inputmode="numeric" placeholder="например, 24">
<p class="hint">Укажите для оценки «не атт», если известны пропуски по любой причине.</p>
</div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<!-- Диалог: индивидуальное задание (защита) --> <!-- Диалог: индивидуальное задание (защита) -->
<!-- Диалог: назначение варианта --> <!-- Диалог: назначение варианта -->
<dialog id="modal-variant" class="dlg dlg-lg"> <dialog id="modal-variant" class="dlg dlg-lg">
@@ -347,7 +436,7 @@
<div class="dlg-body" id="assign-modal-body"></div> <div class="dlg-body" id="assign-modal-body"></div>
</dialog> </dialog>
<script th:src="@{/js/admin-group.js(v='39')}" defer></script> <script th:src="@{/js/admin-group.js(v='40')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -17,7 +17,7 @@
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию --> <!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
<link rel="stylesheet" th:href="@{/css/app.css(v='37')}"> <link rel="stylesheet" th:href="@{/css/app.css(v='38')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}"> <link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled> <link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script> <script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
@@ -25,7 +25,7 @@
<script th:src="@{/js/tablekit.js(v='38')}" defer></script> <script th:src="@{/js/tablekit.js(v='38')}" defer></script>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script> <script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/theme-init.js(v='36')}"></script> <script th:src="@{/js/theme-init.js(v='36')}"></script>
<script th:src="@{/js/app.js(v='38')}" defer></script> <script th:src="@{/js/app.js(v='39')}" defer></script>
<script th:src="@{/js/ui-state.js(v='36')}" defer></script> <script th:src="@{/js/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='39')}" defer></script> <script th:src="@{/js/form-feedback.js(v='39')}" defer></script>
<script th:src="@{/js/modal-ajax.js(v='1')}" defer></script> <script th:src="@{/js/modal-ajax.js(v='1')}" defer></script>

View File

@@ -24,6 +24,30 @@
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a> <a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div> </div>
<h2 class="section-title mt-8">Аттестации</h2>
<div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6">
<table>
<thead>
<tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th><th>Пропущено</th></tr>
</thead>
<tbody>
<tr th:each="a : ${attestations}">
<td th:text="${a.kindLabel}">—</td>
<td th:text="${a.semester}">—</td>
<td>
<span class="badge"
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
th:text="${a.valueLabel}">—</span>
</td>
<td class="text-muted" th:text="${a.missedHours != null ? a.missedHours + ' ч.' : '—'}">—</td>
</tr>
</tbody>
</table>
</div>
<div th:if="${#lists.isEmpty(attestations)}" class="card card-body text-muted mb-6">
Аттестаций пока нет. Преподаватель добавит результаты после их выставления.
</div>
<h2 class="section-title mt-8">Мои лабораторные</h2> <h2 class="section-title mt-8">Мои лабораторные</h2>
<div class="row row-cols-1 row-cols-lg-2 g-4"> <div class="row row-cols-1 row-cols-lg-2 g-4">
<div th:each="c : ${cards}" class="col"><div class="card h-100"> <div th:each="c : ${cards}" class="col"><div class="card h-100">