From fb9511280a28299a12471e9ed626f994b9d8b62c Mon Sep 17 00:00:00 2001
From: Vladislav Kopnin
Date: Mon, 26 Oct 2026 17:15:16 +0300
Subject: [PATCH] fix: polish attestation columns and inline grading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- use numeric semesters 1–8 and add show/hide publication per attestation
- edit attestation results directly in the group table without a modal
- hide unpublished attestations from students
- align header separators, average column and action buttons with the existing UI
- remove missed-hours fields until a separate attendance model is ready
---
.../ysu/lms/progress/AttestationController.kt | 7 +
.../ru/ysu/lms/progress/AttestationService.kt | 37 ++--
.../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 12 +-
.../ru/ysu/lms/progress/domain/Attestation.kt | 4 +-
...__attestation_semester_and_publication.sql | 16 ++
.../web/admin/AdminAttestationController.kt | 34 ++--
.../kotlin/ru/ysu/lms/web/admin/AdminForms.kt | 1 -
.../ysu/lms/web/admin/AdminGroupController.kt | 7 +-
.../ru/ysu/lms/web/admin/AdminLabView.kt | 10 +-
.../lms/web/admin/GroupAttestationViews.kt | 29 ++--
.../ysu/lms/web/api/ApiClientAttestation.kt | 16 ++
.../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 11 +-
.../ru/ysu/lms/web/form/FormFeedback.kt | 1 -
.../ysu/lms/web/student/StudentController.kt | 4 +-
web/src/main/resources/static/css/app.css | 8 +-
.../main/resources/static/js/admin-group.js | 162 ++++++++++++------
web/src/main/resources/static/js/app.js | 1 -
.../main/resources/templates/admin/group.html | 97 +++++------
.../resources/templates/fragments/base.html | 4 +-
.../templates/student/dashboard.html | 5 +-
20 files changed, 290 insertions(+), 176 deletions(-)
create mode 100644 services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt
index dfecdf4..a850d0b 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt
@@ -39,6 +39,13 @@ class AttestationController(
return mapOf("ok" to true)
}
+ @PostMapping("/attestations/{id}/publication")
+ @PreAuthorize("hasAuthority('ROLE_ADMIN')")
+ fun setPublished(
+ @PathVariable id: UUID,
+ @RequestBody body: AttestationPublicationRequest,
+ ): AttestationResponse = service.setPublished(id, body.published)
+
@PostMapping("/attestations/{id}/results")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setResult(
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt
index 7802584..df938fd 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt
@@ -30,27 +30,41 @@ class AttestationService(
groupId: UUID,
studentId: UUID,
): List =
- list(groupId).map { column ->
- column.copy(results = column.results.filter { it.studentId == studentId.toString() })
- }
+ list(groupId)
+ .filter { it.published }
+ .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("Слишком длинное название семестра")
+ if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8")
val column =
attestations.save(
Attestation(
groupId = UUID.fromString(request.groupId),
kind = kind,
- semester = semester,
+ semester = request.semester,
),
)
return toResponse(column, emptyList())
}
+ @Transactional
+ fun setPublished(
+ attestationId: UUID,
+ published: Boolean,
+ ): AttestationResponse {
+ val column =
+ attestations
+ .findById(attestationId)
+ .orElseThrow { NotFoundException("Аттестация не найдена") }
+ column.published = published
+ attestations.save(column)
+ return toResponse(column, results.findByAttestationId(attestationId))
+ }
+
@Transactional
fun delete(attestationId: UUID) {
val column =
@@ -81,15 +95,10 @@ class AttestationService(
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)
@@ -111,17 +120,17 @@ class AttestationService(
groupId = column.groupId.toString(),
kind = column.kind,
semester = column.semester,
+ published = column.published,
results =
rows
.sortedBy { it.studentId }
- .map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) },
+ .map { AttestationResultResponse(it.studentId.toString(), it.value) },
)
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 =
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt
index f4d4953..e22f51d 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt
@@ -167,27 +167,31 @@ data class AttestationCreateRequest(
val groupId: String,
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
val kind: String,
- val semester: String,
+ /** Номер семестра: 1–8. */
+ val semester: Int,
)
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 AttestationPublicationRequest(
+ val published: Boolean,
)
data class AttestationResponse(
val id: String,
val groupId: String,
val kind: String,
- val semester: String,
+ val semester: Int,
+ val published: Boolean = false,
val results: List = emptyList(),
)
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt
index 0e39311..e23ad8f 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt
@@ -16,7 +16,8 @@ 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(nullable = false) var semester: Int = 1,
+ @Column(nullable = false) var published: Boolean = false,
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)
@@ -27,7 +28,6 @@ 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(),
)
diff --git a/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql b/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql
new file mode 100644
index 0000000..b390f9c
--- /dev/null
+++ b/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql
@@ -0,0 +1,16 @@
+-- Семестр аттестации — номер 1–8; пропуски пока не учитываем.
+ALTER TABLE attestations ADD COLUMN semester_no integer;
+
+UPDATE attestations
+SET semester_no = NULLIF(regexp_replace(semester, '[^0-9]', '', 'g'), '')::integer
+WHERE regexp_replace(semester, '[^0-9]', '', 'g') ~ '^[1-8]$';
+
+UPDATE attestations SET semester_no = 1 WHERE semester_no IS NULL;
+
+ALTER TABLE attestations ALTER COLUMN semester_no SET NOT NULL;
+ALTER TABLE attestations DROP COLUMN semester;
+ALTER TABLE attestations RENAME COLUMN semester_no TO semester;
+ALTER TABLE attestations ADD CONSTRAINT attestations_semester_check CHECK (semester BETWEEN 1 AND 8);
+
+ALTER TABLE attestations ADD COLUMN published boolean NOT NULL DEFAULT false;
+ALTER TABLE attestation_results DROP COLUMN missed_hours;
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt
index e16d8fe..693161c 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt
@@ -6,13 +6,17 @@ 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.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.ResponseBody
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.AttestationPublicationPayload
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.setAttestationPublication
import ru.ysu.lms.web.api.setAttestationResult
import ru.ysu.lms.web.session.SessionKeys
@@ -29,8 +33,8 @@ class AdminAttestationController(
redirect: RedirectAttributes,
): String =
try {
- val semester = form.semester.trim()
- if (semester.isBlank()) throw ApiException(400, "Укажите семестр")
+ val semester = form.semester.trim().toIntOrNull()
+ if (semester == null || semester !in 1..8) throw ApiException(400, "Укажите семестр от 1 до 8")
api.createAttestation(
token(session),
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
@@ -72,7 +76,6 @@ class AdminAttestationController(
AttestationResultPayload(
studentId = form.studentId,
value = form.value,
- missedHours = parseMissedHours(form.missedHours),
),
)
redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
@@ -82,13 +85,24 @@ class AdminAttestationController(
"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
- }
+ @PostMapping("/groups/{id}/attestations/{attestationId}/publication")
+ @ResponseBody
+ fun setAttestationPublication(
+ @PathVariable attestationId: String,
+ @RequestParam published: Boolean,
+ session: HttpSession,
+ ): Map =
+ try {
+ val result =
+ api.setAttestationPublication(
+ token(session),
+ attestationId,
+ AttestationPublicationPayload(published),
+ )
+ mapOf("ok" to true, "published" to result.published)
+ } catch (ex: ApiException) {
+ mapOf("ok" to false, "error" to ex.message)
+ }
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
index 2060425..8228e7f 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
@@ -94,7 +94,6 @@ data class SetAttestationResultForm(
val attestationId: String = "",
val studentId: String = "",
val value: String = "",
- val missedHours: String? = null,
)
data class SubmissionDownloadForm(
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
index f186a45..619d327 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
@@ -144,7 +144,6 @@ class AdminGroupController(
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 gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -193,7 +192,7 @@ class AdminGroupController(
groupViewModel(
t,
id,
- GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters),
+ GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
),
)
return "admin/group"
@@ -278,7 +277,6 @@ class AdminGroupController(
val labPublication: Map,
val submissionsBy: Map>,
val attestations: List,
- val attestationSemesters: List,
)
private fun groupViewModel(
@@ -303,7 +301,6 @@ class AdminGroupController(
data.labPublication,
submissionCounts,
data.attestations,
- data.attestationSemesters,
)
}
@@ -315,7 +312,6 @@ class AdminGroupController(
val labPublication: Map,
val submissionCounts: Map,
val attestations: List,
- val attestationSemesters: List,
)
private fun fillGroupModel(
@@ -329,7 +325,6 @@ class AdminGroupController(
model.addAttribute("labPublication", view.labPublication)
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 })
}
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt
index 3e6b8a3..ab27eba 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt
@@ -56,8 +56,12 @@ data class GroupStudentRow(
/** Результат студента по одному столбцу аттестации. */
data class GroupAttestationResult(
val value: String = "",
+)
+
+/** Вариант оценки в выпадающем списке аттестации. */
+data class AttestationOption(
+ val value: String = "",
val label: String = "",
- val missedHours: Int? = null,
)
/** Столбец аттестации на странице группы. */
@@ -65,7 +69,9 @@ data class GroupAttestation(
val id: String = "",
val kind: String = "",
val kindLabel: String = "",
- val semester: String = "",
+ val semester: Int = 1,
+ val published: Boolean = false,
+ val options: List = emptyList(),
val results: Map = emptyMap(),
)
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt
index fd3df90..51b1f07 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt
@@ -2,10 +2,16 @@ 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 {
+ private val VALUES_BY_KIND =
+ mapOf(
+ "intermediate" to listOf("excellent", "satisfactory", "unsatisfactory", "not_attested"),
+ "pass" to listOf("passed", "failed", "absent"),
+ "exam" to listOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"),
+ )
+
fun columns(attestations: List): List =
attestations.map { column ->
GroupAttestation(
@@ -13,27 +19,18 @@ object GroupAttestationViews {
kind = column.kind,
kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester,
+ published = column.published,
+ options =
+ VALUES_BY_KIND[column.kind].orEmpty().map { value ->
+ AttestationOption(value, AttestationPresentation.valueLabel(column.kind, value))
+ },
results =
column.results.associate { row ->
- row.studentId to
- GroupAttestationResult(
- value = row.value,
- label = AttestationPresentation.valueLabel(column.kind, row.value),
- missedHours = row.missedHours,
- )
+ row.studentId to GroupAttestationResult(value = row.value)
},
)
}
- fun semesters(
- labs: List,
- attestations: List,
- ): List =
- (labs.map { it.semesterTitle } + attestations.map { it.semester })
- .filter { it.isNotBlank() && it != "—" }
- .distinct()
- .sorted()
-
fun attach(
rows: List,
columns: List,
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt
index 0b31578..d975bec 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt
@@ -64,6 +64,22 @@ fun ApiClient.setAttestationResult(
.body(AttestationDto::class.java)
}
+fun ApiClient.setAttestationPublication(
+ token: String,
+ attestationId: String,
+ payload: AttestationPublicationPayload,
+): AttestationDto =
+ call {
+ client
+ .post()
+ .uri("/api/v1/attestations/{id}/publication", attestationId)
+ .header(HttpHeaders.AUTHORIZATION, bearer(token))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(payload)
+ .retrieve()
+ .body(AttestationDto::class.java)
+ }
+
fun ApiClient.myAttestations(
token: String,
groupId: String,
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
index 4c1c46a..82824f6 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
@@ -268,7 +268,6 @@ data class GradeDto(
data class AttestationResultDto(
val studentId: String = "",
val value: String = "",
- val missedHours: Int? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -276,20 +275,24 @@ data class AttestationDto(
val id: String = "",
val groupId: String = "",
val kind: String = "",
- val semester: String = "",
+ val semester: Int = 1,
+ val published: Boolean = false,
val results: List = emptyList(),
)
data class AttestationCreatePayload(
val groupId: String,
val kind: String,
- val semester: String,
+ val semester: Int,
)
data class AttestationResultPayload(
val studentId: String,
val value: String,
- val missedHours: Int? = null,
+)
+
+data class AttestationPublicationPayload(
+ val published: Boolean,
)
@JsonIgnoreProperties(ignoreUnknown = true)
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt
index ed7c14e..829307b 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt
@@ -55,7 +55,6 @@ data class FormFeedback(
Rule(Regex("(?iu)номер варианта"), listOf("number")),
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
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")),
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
index 7a88fd0..68d9d66 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
@@ -63,10 +63,9 @@ class StudentController(
data class StudentAttestationView(
val kindLabel: String,
- val semester: String,
+ val semester: Int,
val valueLabel: String,
val positive: Boolean?,
- val missedHours: Int?,
)
@GetMapping
@@ -98,7 +97,6 @@ class StudentController(
semester = column.semester,
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
- missedHours = result?.missedHours,
)
},
)
diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css
index fb02ac0..526831c 100644
--- a/web/src/main/resources/static/css/app.css
+++ b/web/src/main/resources/static/css/app.css
@@ -557,11 +557,13 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; }
.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 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; }
+.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); }
+.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); }
+.matrix th.average-col { min-width: 7rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); }
+.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); border-right: 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); }
+.attestation-select { width: 100%; min-width: 8.5rem; font-size: 0.8125rem; }
.matrix td.student-cell { min-width: 12rem; }
.student-name { font-weight: 600; font-size: 0.84375rem; }
.student-cell .font-mono { margin-top: 0.125rem; }
diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js
index 77d2036..6e01311 100644
--- a/web/src/main/resources/static/js/admin-group.js
+++ b/web/src/main/resources/static/js/admin-group.js
@@ -32,59 +32,6 @@ function openGradeEditor(btn) {
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) {
var box = $id('cs-rows');
var row = document.createElement('div');
@@ -726,6 +673,115 @@ function submitGradeForm(form) {
});
}
+/* ---------- Аттестация: сохранение прямо из ячейки ---------- */
+function submitAttestationForm(form) {
+ if (form.dataset.saving === '1') return;
+ form.dataset.saving = '1';
+ var select = form.querySelector('select[name="value"]');
+ if (select) select.disabled = true;
+
+ fetch(form.action, {
+ method: 'POST',
+ body: new FormData(form),
+ headers: { 'X-Requested-With': 'fetch' }
+ })
+ .then(function (response) {
+ if (response.redirected && new URL(response.url, location.href).pathname === '/login') {
+ window.location.assign(response.url);
+ return null;
+ }
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ return response.text();
+ })
+ .then(function (html) {
+ if (!html) return;
+ var doc = new DOMParser().parseFromString(html, 'text/html');
+ var feedbackNode = doc.getElementById('form-feedback');
+ if (feedbackNode) {
+ var feedback = null;
+ try {
+ feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
+ } catch (e) {
+ feedback = null;
+ }
+ if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
+ window.lmsApplyFormFeedback(feedback);
+ }
+ var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
+ showToast(errorToast
+ ? errorToast.textContent.trim()
+ : ((feedback && feedback.general) || 'Не удалось сохранить результат'), 'error');
+ return;
+ }
+ var error = doc.querySelector('.toast-item.toast-error .toast-text');
+ if (error) {
+ showToast(error.textContent.trim(), 'error');
+ return;
+ }
+ var notice = doc.querySelector('.toast-item.toast-success .toast-text');
+ showToast(notice ? notice.textContent.trim() : 'Результат аттестации сохранён', 'success');
+ })
+ .catch(function (error) {
+ showToast('Не удалось сохранить результат: ' + error.message, 'error');
+ })
+ .finally(function () {
+ form.dataset.saving = '';
+ if (select) select.disabled = false;
+ });
+}
+
+document.addEventListener('submit', function (ev) {
+ var form = ev.target;
+ if (!form || !form.classList || !form.classList.contains('js-attestation-form')) return;
+ ev.preventDefault();
+ submitAttestationForm(form);
+}, true);
+
+/* ---------- Показ/скрытие столбца аттестации ---------- */
+document.addEventListener('submit', function (ev) {
+ var form = ev.target;
+ if (!form || !form.classList || !form.classList.contains('js-att-pub-form')) return;
+ ev.preventDefault();
+ var button = form.querySelector('button[type="submit"]');
+ if (button) button.disabled = true;
+
+ fetch(form.action, {
+ method: 'POST',
+ body: new FormData(form),
+ headers: { 'X-Requested-With': 'fetch' }
+ })
+ .then(function (response) {
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ return response.json();
+ })
+ .then(function (result) {
+ if (!result || !result.ok) {
+ showToast((result && result.error) || 'Не удалось изменить публикацию аттестации', 'error');
+ return;
+ }
+ var published = !!result.published;
+ var hidden = form.querySelector('input[name="published"]');
+ if (hidden) hidden.value = published ? 'false' : 'true';
+ if (button) {
+ button.classList.toggle('is-all', published);
+ button.classList.toggle('is-empty', !published);
+ button.title = published
+ ? 'Аттестация видна студентам — скрыть'
+ : 'Аттестация скрыта — показать студентам';
+ }
+ var head = form.closest('th');
+ var badge = head ? head.querySelector('.js-att-hidden') : null;
+ if (badge) badge.hidden = published;
+ showToast(published ? 'Аттестация открыта студентам' : 'Аттестация скрыта от студентов', 'success');
+ })
+ .catch(function (error) {
+ showToast('Не удалось изменить публикацию: ' + error.message, 'error');
+ })
+ .finally(function () {
+ if (button) button.disabled = false;
+ });
+}, true);
+
document.addEventListener('submit', function (ev) {
if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize();
diff --git a/web/src/main/resources/static/js/app.js b/web/src/main/resources/static/js/app.js
index b1b73ea..abd376c 100644
--- a/web/src/main/resources/static/js/app.js
+++ b/web/src/main/resources/static/js/app.js
@@ -270,7 +270,6 @@
var kind = action.getAttribute('data-action');
var fn = {
grade: window.openGradeEditor,
- attestation: window.openAttestation,
variant: window.openVariant,
defense: window.openDefense,
assign: window.openLabAssign,
diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html
index 1fd44a7..5c0d5f3 100644
--- a/web/src/main/resources/templates/admin/group.html
+++ b/web/src/main/resources/templates/admin/group.html
@@ -16,7 +16,9 @@
@@ -71,21 +73,35 @@
- Средний балл |
+ Средний балл |
+
Аттестация
- —
+ Семестр 1
+ скрыто
|
Пароль |
@@ -138,18 +154,21 @@
- — |
-
+
|
временный
@@ -220,48 +239,24 @@
-
-
- Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.
+
+ Номер семестра от 1 до 8. Он будет указан в ведомости и кабинете студента.
-
-
-
-
+
|