16 Commits

Author SHA1 Message Date
920d96a0f5 fix: скрыть админские действия статей от студентов 2026-10-29 12:01:26 +03:00
2beffd84f1 fix: move password status marker to student number 2026-10-26 20:11:37 +03:00
d8e28e88fc fix: center attestation values and move password status
- widen attestation columns so full grade labels fit at the common font size
- center attestation values like the average score
- remove the password column and mark temporary/changed passwords with a square dot next to the student name
2026-10-26 20:02:18 +03:00
44edb5ebeb fix: restore uniform matrix font size and header spacing
- use the previous student-name size (13.5px) for all matrix cell text
- match attestation header padding to neighboring headers
- widen the attestation column just enough for full grade labels
2026-10-26 19:56:36 +03:00
e2e3ed1eaf fix: unify group matrix typography and dashboard order
- use one 12px font size for all matrix cell text
- center average score values while keeping the header left-aligned
- swap Groups and Labs panels on the admin dashboard
2026-10-26 19:49:22 +03:00
7fb3e13548 fix: refine attestation column borders and labels
- remove doubled left borders and add a single border before password/actions
- narrow attestation columns and fit full grade labels
- use full intermediate attestation labels: отлично, аттестован, неудовлетворительно, не аттестован
- keep the average header left-aligned
2026-10-26 19:38:13 +03:00
9ddf2c6ce9 fix: align attestation action buttons with lab columns
Normalize all header action buttons to the same height and icon sizing; verify computed sizes in Chromium.
2026-10-26 18:45:03 +03:00
a336612ad5 fix: polish group matrix headers and cell alignment
- shorten intermediate attestation header to «Пр. атт.»
- vertically center row cell contents and left-align values
- use normal header color/weight for average, password and actions
- align attestation action buttons to equal height
- add a divider before the actions column
2026-10-26 18:29:22 +03:00
c8581c620a fix: align attestation title with lab headers
Use normal font weight and left alignment for the attestation column title and semester.
2026-10-26 18:14:10 +03:00
54e5721267 fix: remove hidden badge from attestation header
The eye icon already communicates whether an attestation is visible to students.
2026-10-26 18:03:33 +03:00
26573f2210 revert: keep feature-branch migration out of master
V10 belongs to the unfinished groups/courses branch and must not be pulled into master. Production already has V11/V12 applied; master starts with that migration history without V10 and without out-of-order mode.
2026-10-26 17:57:12 +03:00
bfdae41fb8 fix: allow out-of-order progress migrations
V10 came from the feature branch after V11/V12 were already applied in production. It is safe to apply after them and adds only profile author columns plus an index.
2026-10-26 17:45:14 +03:00
9e16566160 fix: keep group page working when attestations fail
- tolerate legacy textual semester values from older progress-service responses
- treat attestations as optional data on the group page and log loading failures instead of returning 500
2026-10-26 17:37:57 +03:00
dc13ba3186 chore: keep progress migrations compatible with feature branch
The current local database already has V10 from the groups/courses branch; include it so master can start without a migration history gap.
2026-10-26 17:31:30 +03:00
f5ba21b5f5 fix: match attestation columns to lab result styling
- use the same header order and compact action row as lab columns
- show attestation results as centered text-like selects instead of boxed inputs
- keep column widths and separators aligned with lab columns
- send the selected value with the inline attestation request
2026-10-26 17:30:55 +03:00
fb9511280a fix: polish attestation columns and inline grading
- 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
2026-10-26 17:15:16 +03:00
24 changed files with 529 additions and 222 deletions

View File

@@ -39,6 +39,13 @@ class AttestationController(
return mapOf("ok" to true) 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") @PostMapping("/attestations/{id}/results")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setResult( fun setResult(

View File

@@ -30,27 +30,41 @@ class AttestationService(
groupId: UUID, groupId: UUID,
studentId: UUID, studentId: UUID,
): List<AttestationResponse> = ): List<AttestationResponse> =
list(groupId).map { column -> list(groupId)
column.copy(results = column.results.filter { it.studentId == studentId.toString() }) .filter { it.published }
} .map { column ->
column.copy(results = column.results.filter { it.studentId == studentId.toString() })
}
@Transactional @Transactional
fun create(request: AttestationCreateRequest): AttestationResponse { fun create(request: AttestationCreateRequest): AttestationResponse {
val kind = normalizeKind(request.kind) val kind = normalizeKind(request.kind)
val semester = request.semester.trim() if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8")
if (semester.isEmpty()) throw BadRequestException("Укажите семестр")
if (semester.length > MAX_SEMESTER_LENGTH) throw BadRequestException("Слишком длинное название семестра")
val column = val column =
attestations.save( attestations.save(
Attestation( Attestation(
groupId = UUID.fromString(request.groupId), groupId = UUID.fromString(request.groupId),
kind = kind, kind = kind,
semester = semester, semester = request.semester,
), ),
) )
return toResponse(column, emptyList()) 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 @Transactional
fun delete(attestationId: UUID) { fun delete(attestationId: UUID) {
val column = val column =
@@ -81,15 +95,10 @@ class AttestationService(
val allowed = VALUES[column.kind] ?: emptySet() val allowed = VALUES[column.kind] ?: emptySet()
if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации") 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 = val entity =
(existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply { (existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply {
this.value = value this.value = value
this.missedHours = missedHours
this.updatedAt = Instant.now() this.updatedAt = Instant.now()
} }
results.save(entity) results.save(entity)
@@ -111,17 +120,17 @@ class AttestationService(
groupId = column.groupId.toString(), groupId = column.groupId.toString(),
kind = column.kind, kind = column.kind,
semester = column.semester, semester = column.semester,
published = column.published,
results = results =
rows rows
.sortedBy { it.studentId } .sortedBy { it.studentId }
.map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) }, .map { AttestationResultResponse(it.studentId.toString(), it.value) },
) )
companion object { companion object {
const val KIND_INTERMEDIATE = "intermediate" const val KIND_INTERMEDIATE = "intermediate"
const val KIND_PASS = "pass" const val KIND_PASS = "pass"
const val KIND_EXAM = "exam" const val KIND_EXAM = "exam"
private const val MAX_SEMESTER_LENGTH = 100
private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM) private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM)
private val VALUES = private val VALUES =

View File

@@ -167,27 +167,31 @@ data class AttestationCreateRequest(
val groupId: String, val groupId: String,
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */ /** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
val kind: String, val kind: String,
val semester: String, /** Номер семестра: 1–8. */
val semester: Int,
) )
data class AttestationResultRequest( data class AttestationResultRequest(
val studentId: String, val studentId: String,
/** Пустое значение очищает результат. */ /** Пустое значение очищает результат. */
val value: String = "", val value: String = "",
val missedHours: Int? = null,
) )
data class AttestationResultResponse( data class AttestationResultResponse(
val studentId: String, val studentId: String,
val value: String, val value: String,
val missedHours: Int? = null, )
data class AttestationPublicationRequest(
val published: Boolean,
) )
data class AttestationResponse( data class AttestationResponse(
val id: String, val id: String,
val groupId: String, val groupId: String,
val kind: String, val kind: String,
val semester: String, val semester: Int,
val published: Boolean = false,
val results: List<AttestationResultResponse> = emptyList(), val results: List<AttestationResultResponse> = emptyList(),
) )

View File

@@ -16,7 +16,8 @@ class Attestation(
@Id var id: UUID = Ids.v7(), @Id var id: UUID = Ids.v7(),
@Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0), @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 = 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(), @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 = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
@Id @Column(name = "student_id", nullable = false) var studentId: 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(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(), @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
) )

View File

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

View File

@@ -13,10 +13,10 @@ object AttestationPresentation {
mapOf( mapOf(
"intermediate" to "intermediate" to
mapOf( mapOf(
"excellent" to "отл", "excellent" to "отлично",
"satisfactory" to "атт", "satisfactory" to "аттестован",
"unsatisfactory" to "неуд", "unsatisfactory" to "неудовлетворительно",
"not_attested" to "не атт", "not_attested" to "не аттестован",
), ),
"pass" to "pass" to
mapOf( mapOf(
@@ -43,6 +43,22 @@ object AttestationPresentation {
fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind
/** Короткая подпись для шапки узкого столбца. */
fun shortKindLabel(kind: String): String =
when (kind) {
"intermediate" -> "Пр. атт."
"pass" -> "Зачёт"
"exam" -> "Экзамен"
else -> kind
}
/** Понимает и «2», и старые значения вроде «2 семестр»; вне 1–8 — 1. */
fun semesterNumber(raw: String): Int {
val digits = raw.filter { it.isDigit() }
val value = digits.toIntOrNull() ?: return 1
return if (value in 1..8) value else 1
}
fun valueLabel( fun valueLabel(
kind: String, kind: String,
value: String, value: String,

View File

@@ -6,13 +6,17 @@ import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping 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 org.springframework.web.servlet.mvc.support.RedirectAttributes
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
import ru.ysu.lms.web.api.AttestationCreatePayload 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.AttestationResultPayload
import ru.ysu.lms.web.api.createAttestation import ru.ysu.lms.web.api.createAttestation
import ru.ysu.lms.web.api.deleteAttestation 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.api.setAttestationResult
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
@@ -29,8 +33,8 @@ class AdminAttestationController(
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val semester = form.semester.trim() val semester = form.semester.trim().toIntOrNull()
if (semester.isBlank()) throw ApiException(400, "Укажите семестр") if (semester == null || semester !in 1..8) throw ApiException(400, "Укажите семестр от 1 до 8")
api.createAttestation( api.createAttestation(
token(session), token(session),
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester), AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
@@ -72,7 +76,6 @@ class AdminAttestationController(
AttestationResultPayload( AttestationResultPayload(
studentId = form.studentId, studentId = form.studentId,
value = form.value, value = form.value,
missedHours = parseMissedHours(form.missedHours),
), ),
) )
redirect.addFlashAttribute("notice", "Результат аттестации сохранён") redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
@@ -82,13 +85,24 @@ class AdminAttestationController(
"redirect:/admin/groups/$id" "redirect:/admin/groups/$id"
} }
private fun parseMissedHours(raw: String?): Int? { @PostMapping("/groups/{id}/attestations/{attestationId}/publication")
val text = raw?.trim().orEmpty() @ResponseBody
if (text.isEmpty()) return null fun setAttestationPublication(
val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым") @PathVariable attestationId: String,
if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным") @RequestParam published: Boolean,
return value session: HttpSession,
} ): Map<String, Any> =
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 = private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String session.getAttribute(SessionKeys.TOKEN) as? String

View File

@@ -94,7 +94,6 @@ data class SetAttestationResultForm(
val attestationId: String = "", val attestationId: String = "",
val studentId: String = "", val studentId: String = "",
val value: String = "", val value: String = "",
val missedHours: String? = null,
) )
data class SubmissionDownloadForm( data class SubmissionDownloadForm(

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.web.admin package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -59,6 +60,8 @@ class AdminGroupController(
markdown: MarkdownRenderer, markdown: MarkdownRenderer,
mapper: ObjectMapper, mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) { ) : AdminSupport(api, markdown, mapper) {
private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
@GetMapping @GetMapping
fun dashboard( fun dashboard(
session: HttpSession, session: HttpSession,
@@ -143,8 +146,7 @@ class AdminGroupController(
val allLabs = api.listLabs(t) val allLabs = api.listLabs(t)
val group = api.groupDetail(t, id) val group = api.groupDetail(t, id)
val labs = allLabs.filter { it.status == "published" } val labs = allLabs.filter { it.status == "published" }
val attestations = GroupAttestationViews.columns(api.listAttestations(t, id)) val attestations = loadAttestations(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 }
@@ -193,7 +195,7 @@ class AdminGroupController(
groupViewModel( groupViewModel(
t, t,
id, id,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters), GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
), ),
) )
return "admin/group" return "admin/group"
@@ -261,6 +263,17 @@ class AdminGroupController(
) )
} }
/** Аттестации не должны ронять всю ведомость, если сервис ещё не обновлён. */
private fun loadAttestations(
token: String,
groupId: String,
): List<GroupAttestation> =
GroupAttestationViews.columns(
runCatching { api.listAttestations(token, groupId) }
.onFailure { log.warn("Не удалось загрузить аттестации группы {}", groupId, it) }
.getOrDefault(emptyList()),
)
private fun averageScore(cells: List<GroupLabCell>): String? { private fun averageScore(cells: List<GroupLabCell>): String? {
val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() } val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
if (scores.isEmpty()) return null if (scores.isEmpty()) return null
@@ -278,7 +291,6 @@ class AdminGroupController(
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 attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun groupViewModel( private fun groupViewModel(
@@ -303,7 +315,6 @@ class AdminGroupController(
data.labPublication, data.labPublication,
submissionCounts, submissionCounts,
data.attestations, data.attestations,
data.attestationSemesters,
) )
} }
@@ -315,7 +326,6 @@ class AdminGroupController(
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 attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun fillGroupModel( private fun fillGroupModel(
@@ -329,7 +339,6 @@ class AdminGroupController(
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("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

@@ -56,8 +56,12 @@ data class GroupStudentRow(
/** Результат студента по одному столбцу аттестации. */ /** Результат студента по одному столбцу аттестации. */
data class GroupAttestationResult( data class GroupAttestationResult(
val value: String = "", val value: String = "",
)
/** Вариант оценки в выпадающем списке аттестации. */
data class AttestationOption(
val value: String = "",
val label: String = "", val label: String = "",
val missedHours: Int? = null,
) )
/** Столбец аттестации на странице группы. */ /** Столбец аттестации на странице группы. */
@@ -65,7 +69,10 @@ data class GroupAttestation(
val id: String = "", val id: String = "",
val kind: String = "", val kind: String = "",
val kindLabel: String = "", val kindLabel: String = "",
val semester: String = "", val shortLabel: String = "",
val semester: Int = 1,
val published: Boolean = false,
val options: List<AttestationOption> = emptyList(),
val results: Map<String, GroupAttestationResult> = emptyMap(), val results: Map<String, GroupAttestationResult> = emptyMap(),
) )

View File

@@ -2,38 +2,36 @@ package ru.ysu.lms.web.admin
import ru.ysu.lms.web.AttestationPresentation import ru.ysu.lms.web.AttestationPresentation
import ru.ysu.lms.web.api.AttestationDto import ru.ysu.lms.web.api.AttestationDto
import ru.ysu.lms.web.api.LabSummaryDto
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */ /** Преобразование ответов сервиса аттестаций в модель страницы группы. */
object GroupAttestationViews { 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<AttestationDto>): List<GroupAttestation> = fun columns(attestations: List<AttestationDto>): List<GroupAttestation> =
attestations.map { column -> attestations.map { column ->
GroupAttestation( GroupAttestation(
id = column.id, id = column.id,
kind = column.kind, kind = column.kind,
kindLabel = AttestationPresentation.kindLabel(column.kind), kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester, shortLabel = AttestationPresentation.shortKindLabel(column.kind),
semester = AttestationPresentation.semesterNumber(column.semester),
published = column.published,
options =
VALUES_BY_KIND[column.kind].orEmpty().map { value ->
AttestationOption(value, AttestationPresentation.valueLabel(column.kind, value))
},
results = results =
column.results.associate { row -> column.results.associate { row ->
row.studentId to row.studentId to GroupAttestationResult(value = row.value)
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( fun attach(
rows: List<GroupStudentRow>, rows: List<GroupStudentRow>,
columns: List<GroupAttestation>, columns: List<GroupAttestation>,

View File

@@ -64,6 +64,22 @@ fun ApiClient.setAttestationResult(
.body(AttestationDto::class.java) .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( fun ApiClient.myAttestations(
token: String, token: String,
groupId: String, groupId: String,

View File

@@ -268,7 +268,6 @@ data class GradeDto(
data class AttestationResultDto( data class AttestationResultDto(
val studentId: String = "", val studentId: String = "",
val value: String = "", val value: String = "",
val missedHours: Int? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -276,20 +275,25 @@ data class AttestationDto(
val id: String = "", val id: String = "",
val groupId: String = "", val groupId: String = "",
val kind: String = "", val kind: String = "",
val semester: String = "", /** Строка — чтобы старые ответы с текстовым семестром не ломали разбор. */
val semester: String = "1",
val published: Boolean = false,
val results: List<AttestationResultDto> = emptyList(), val results: List<AttestationResultDto> = emptyList(),
) )
data class AttestationCreatePayload( data class AttestationCreatePayload(
val groupId: String, val groupId: String,
val kind: String, val kind: String,
val semester: String, val semester: Int,
) )
data class AttestationResultPayload( data class AttestationResultPayload(
val studentId: String, val studentId: String,
val value: String, val value: String,
val missedHours: Int? = null, )
data class AttestationPublicationPayload(
val published: Boolean,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -55,7 +55,6 @@ data class FormFeedback(
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", "value")), Rule(Regex("(?iu)оценк"), listOf("score", "value")),
Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")),
Rule(Regex("(?iu)семестр"), listOf("semester")), Rule(Regex("(?iu)семестр"), listOf("semester")),
Rule(Regex("(?iu)тип аттестации"), listOf("kind")), Rule(Regex("(?iu)тип аттестации"), listOf("kind")),
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")), Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),

View File

@@ -63,10 +63,9 @@ class StudentController(
data class StudentAttestationView( data class StudentAttestationView(
val kindLabel: String, val kindLabel: String,
val semester: String, val semester: Int,
val valueLabel: String, val valueLabel: String,
val positive: Boolean?, val positive: Boolean?,
val missedHours: Int?,
) )
@GetMapping @GetMapping
@@ -95,10 +94,9 @@ class StudentController(
val result = column.results.firstOrNull() val result = column.results.firstOrNull()
StudentAttestationView( StudentAttestationView(
kindLabel = AttestationPresentation.kindLabel(column.kind), kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester, semester = AttestationPresentation.semesterNumber(column.semester),
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—", valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) }, positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
missedHours = result?.missedHours,
) )
}, },
) )

View File

@@ -467,7 +467,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
.variant-btn.manual .cell-none { font-weight: 600; } .variant-btn.manual .cell-none { font-weight: 600; }
.score-link { .score-link {
display: inline-block; padding: 0; border: none; background: none; display: inline-block; padding: 0; border: none; background: none;
font-size: 0.8125rem; font-weight: 600; font-variant-numeric: tabular-nums; font-size: 0.84375rem; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--c-text); cursor: pointer; color: var(--c-text); cursor: pointer;
} }
.score-link:hover { color: var(--c-accent); text-decoration: underline; } .score-link:hover { color: var(--c-accent); text-decoration: underline; }
@@ -495,7 +495,12 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
.cell-btn.st.dim::before { background: var(--c-border-strong); } .cell-btn.st.dim::before { background: var(--c-border-strong); }
.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); } .mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); }
.defense-cell-timer { font-variant-numeric: tabular-nums; } .defense-cell-timer { font-variant-numeric: tabular-nums; }
.lab-head-actions { display: flex; gap: 0.2rem; } .lab-head-actions { display: flex; gap: 0.2rem; align-items: center; }
.lab-head-actions .btn {
height: 2.25rem; min-height: 2.25rem; padding: 0 0.5rem;
display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box;
}
.lab-head-actions .ico { font-size: 0.95rem; line-height: 1; }
.btn btn-secondary btn-sm p-2 { .btn btn-secondary btn-sm p-2 {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer; min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer;
@@ -546,23 +551,72 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; }
.scroll-x { overflow-x: auto; } .scroll-x { overflow-x: auto; }
.matrix { border-collapse: separate; border-spacing: 0; } .matrix { border-collapse: separate; border-spacing: 0; }
.matrix th { .matrix th {
vertical-align: top; padding: 0.75rem 1rem; text-align: left; vertical-align: middle; padding: 0.75rem 1rem; text-align: left;
font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted); font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted);
background: var(--c-surface); white-space: nowrap; background: var(--c-surface); white-space: nowrap;
} }
.matrix td { vertical-align: top; padding: 1rem; } .matrix td { vertical-align: middle; padding: 1rem; font-size: 0.84375rem; }
#group-table td .student-name,
#group-table td .score-link,
#group-table td .attestation-select,
#group-table td .cell-sub,
#group-table td .sub-link,
#group-table td .cell-btn.st,
#group-table td .font-mono,
#group-table td .mini-count,
#group-table td .badge,
#group-table td .btn { font-size: 0.84375rem; }
/* Непрозрачный фон обязателен: закреплённый столбец не должен просвечивать при прокрутке. */ /* Непрозрачный фон обязателен: закреплённый столбец не должен просвечивать при прокрутке. */
.matrix tbody tr:hover td { background: var(--c-row-hover); } .matrix tbody tr:hover td { background: var(--c-row-hover); }
.matrix tbody tr:hover td.matrix-sticky { background: var(--c-row-hover); } .matrix tbody tr:hover td.matrix-sticky { background: var(--c-row-hover); }
.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); } #group-table th.average-col,
.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); } #group-table th.actions-head { color: var(--c-text); font-weight: 400; }
.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); font-weight: 600; } #group-table th.actions-head,
.attestation-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; text-align: left; } #group-table td.actions-cell { border-left: 1px solid var(--c-border); }
.attestation-kind { font-weight: 600; color: var(--c-text); } .matrix th.attestation-col {
width: 12rem; min-width: 11.5rem; max-width: 12rem;
border-left: 1px solid var(--c-border);
}
.matrix td.attestation-cell {
width: 12rem; min-width: 11.5rem; max-width: 12rem;
padding-left: 0.35rem; padding-right: 0.35rem;
border-left: 1px solid var(--c-border);
}
.matrix th.average-col,
.matrix td.average-cell {
min-width: 7rem; border-left: 1px solid var(--c-border); text-align: left;
}
.matrix td.average-cell { font-weight: 600; text-align: center; }
.attestation-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; width: 100%; max-width: 9rem; text-align: left; }
.attestation-kind { font-weight: 400; color: var(--c-text); white-space: normal; text-align: left; }
.attestation-col .lab-col-head, .attestation-col .lab-col-title { text-align: left; }
.js-attestation-form { display: flex; justify-content: center; width: 100%; }
.attestation-select {
appearance: none; -webkit-appearance: none;
width: 100%; max-width: 100%; min-width: 0; padding: 0.15rem 0.95rem 0.15rem 0.15rem;
border: 1px solid transparent; border-radius: var(--radius-sm);
background-color: transparent; color: var(--c-text);
font-size: 0.84375rem; font-weight: 600; font-variant-numeric: tabular-nums;
text-align: center; text-align-last: center; cursor: pointer;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 8px) 55%, calc(100% - 3px) 55%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.attestation-select:hover { color: var(--c-accent); text-decoration: underline; }
.attestation-select:focus { outline: none; border-color: var(--c-accent); box-shadow: 0 0 0 3px var(--c-accent-soft); }
.attestation-select.none { color: var(--c-text-muted); font-weight: 400; }
.attestation-select:disabled { opacity: 0.6; cursor: wait; }
.attestation-select option { text-align: left; text-align-last: left; background: var(--c-surface); color: var(--c-text); }
.matrix td.student-cell { min-width: 12rem; } .matrix td.student-cell { min-width: 12rem; }
.student-no-line { display: flex; align-items: center; gap: 0.35rem; }
.password-dot { width: 6px; height: 6px; flex: 0 0 auto; background: var(--c-tone-pos); }
.password-dot.warn { background: #b45309; }
.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; }
.lab-col-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; } .lab-col-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }

View File

@@ -32,59 +32,6 @@ 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');
@@ -726,6 +673,113 @@ function submitGradeForm(form) {
}); });
} }
/* ---------- Аттестация: сохранение прямо из ячейки ---------- */
function submitAttestationForm(form) {
if (form.dataset.saving === '1') return;
form.dataset.saving = '1';
var select = form.querySelector('select[name="value"]');
var payload = new FormData(form);
if (select) select.disabled = true;
fetch(form.action, {
method: 'POST',
body: payload,
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
? 'Аттестация видна студентам — скрыть'
: 'Аттестация скрыта — показать студентам';
}
showToast(published ? 'Аттестация открыта студентам' : 'Аттестация скрыта от студентов', 'success');
})
.catch(function (error) {
showToast('Не удалось изменить публикацию: ' + error.message, 'error');
})
.finally(function () {
if (button) button.disabled = false;
});
}, true);
document.addEventListener('submit', function (ev) { document.addEventListener('submit', function (ev) {
if (!ev.target || ev.target.id !== 'grade-form') return; if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize(); csSerialize();

View File

@@ -270,7 +270,6 @@
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

@@ -16,23 +16,6 @@
</div> </div>
<div class="row g-4 mt-4"> <div class="row g-4 mt-4">
<div class="col-lg-6">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title m-0">Лабораторные</h2>
<a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
</div>
<div class="card card-flush">
<table>
<tbody>
<tr th:each="lab : ${labs}">
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-6"> <div class="col-lg-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="section-title m-0">Группы</h2> <h2 class="section-title m-0">Группы</h2>
@@ -50,6 +33,23 @@
</table> </table>
</div> </div>
</div> </div>
<div class="col-lg-6">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title m-0">Лабораторные</h2>
<a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
</div>
<div class="card card-flush">
<table>
<tbody>
<tr th:each="lab : ${labs}">
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -16,7 +16,9 @@
</p> </p>
</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')}">
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
</a>
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button> <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>
@@ -71,32 +73,51 @@
</div> </div>
</div> </div>
</th> </th>
<th class="text-center" title="Средний балл по выставленным оценкам">Средний балл</th> <th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort> <th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
<div class="attestation-head"> <div class="lab-col-head">
<div class="attestation-title">
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
</div>
<span class="lab-col-title" th:text="'Семестр ' + ${att.semester}">Семестр 1</span>
<div class="lab-head-actions"> <div class="lab-head-actions">
<form class="inline-form js-att-pub-form" method="post"
th:action="@{/admin/groups/{gid}/attestations/{aid}/publication(gid=${group.id}, aid=${att.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!att.published}">
<button class="btn btn-secondary btn-sm p-2 js-pub-eye js-att-pub" type="submit"
th:classappend="${att.published} ? 'is-all' : 'is-empty'"
th:title="${att.published} ? 'Аттестация видна студентам — скрыть' : 'Аттестация скрыта — показать студентам'">
<i class="eye-open ico bi bi-eye" aria-hidden="true"></i>
<i class="eye-closed ico bi bi-eye-slash" aria-hidden="true"></i>
</button>
</form>
<form class="inline-form" <form class="inline-form"
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}" th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
method="post" method="post"
data-confirm="Удалить столбец аттестации вместе со всеми результатами?"> data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm p-2" type="submit" <button class="btn btn-danger btn-sm p-2" type="submit"
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button> title="Удалить аттестацию" aria-label="Удалить аттестацию">
<i class="cell-ico ico bi bi-trash" aria-hidden="true"></i>
</button>
</form> </form>
</div> </div>
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
<span class="lab-col-title" th:text="${att.semester}">—</span>
</div> </div>
</th> </th>
<th data-nosort>Пароль</th> <th class="text-right actions-head" data-nosort>Действия</th>
<th class="text-right" data-nosort>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky"> <td class="student-cell matrix-sticky">
<div class="student-name" th:text="${row.fullName}">—</div> <div class="student-name" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div> <div class="student-no-line">
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
aria-label="Статус пароля"></span>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</div>
</td> </td>
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab" <td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}"> th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
@@ -138,22 +159,22 @@
</div> </div>
</div> </div>
</td> </td>
<td class="text-center font-mono average-cell" <td class="average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td> th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="text-center attestation-cell"> <td th:each="att : ${attestations}" class="attestation-cell">
<button class="score-link" type="button" <form class="inline-form js-attestation-form" method="post"
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''" th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
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}" <input type="hidden" name="_csrf" th:value="${csrfToken}">
data-action="attestation" <input type="hidden" name="attestationId" th:value="${att.id}">
th:title="${att.kindLabel} + ' — ' + ${att.semester}"> <input type="hidden" name="studentId" th:value="${row.studentId}">
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span> <select class="attestation-select" name="value" data-auto-submit
<span th:if="${row.attestations[att.id]?.missedHours != null}" th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span> th:title="${row.fullName} + ' — ' + ${att.kindLabel} + ' (' + 'Семестр ' + ${att.semester} + ')'">
</button> <option value="" th:selected="${row.attestations[att.id] == null}">—</option>
</td> <option th:each="opt : ${att.options}" th:value="${opt.value}" th:text="${opt.label}"
<td class="text-center"> th:selected="${row.attestations[att.id]?.value == opt.value}"></option>
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span> </select>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span> </form>
</td> </td>
<td class="text-right actions-cell"> <td class="text-right actions-cell">
<div class="actions-col"> <div class="actions-col">
@@ -170,7 +191,7 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 4}">В группе нет студентов.</td></tr> <tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 3}">В группе нет студентов.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -220,48 +241,24 @@
<option value="exam">Экзамен</option> <option value="exam">Экзамен</option>
</select></div> </select></div>
<div><label class="label" for="attestation-semester">Семестр</label> <div><label class="label" for="attestation-semester">Семестр</label>
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters" <select class="input" id="attestation-semester" name="semester" required
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required> data-error-required="Укажите семестр от 1 до 8">
<datalist id="attestation-semesters"> <option value="1">Семестр 1</option>
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option> <option value="2">Семестр 2</option>
</datalist> <option value="3">Семестр 3</option>
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p> <option value="4">Семестр 4</option>
<option value="5">Семестр 5</option>
<option value="6">Семестр 6</option>
<option value="7">Семестр 7</option>
<option value="8">Семестр 8</option>
</select>
<p class="hint">Номер семестра от 1 до 8. Он будет указан в ведомости и кабинете студента.</p>
</div> </div>
<button class="btn btn-primary" type="submit">Добавить столбец</button> <button class="btn btn-primary" type="submit">Добавить столбец</button>
</form> </form>
</div> </div>
</dialog> </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">
@@ -436,7 +433,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='40')}" defer></script> <script th:src="@{/js/admin-group.js(v='43')}" 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='38')}"> <link rel="stylesheet" th:href="@{/css/app.css(v='52')}">
<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='39')}" defer></script> <script th:src="@{/js/app.js(v='40')}" 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

@@ -44,7 +44,7 @@
<div class="card card-flush"> <div class="card card-flush">
<table> <table>
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead> <thead><tr><th>Статья</th><th class="th-w-md" data-nosort th:if="${currentUser != null and currentUser.admin}">Действия</th></tr></thead>
<tbody data-article-list> <tbody data-article-list>
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}"> <tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<td> <td>
@@ -55,7 +55,7 @@
th:text="${article.title}">—</a> th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span> <span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td> </td>
<td class="text-right actions-cell"> <td class="text-right actions-cell" th:if="${currentUser != null and currentUser.admin}">
<a class="btn btn-secondary btn-sm p-2" title="Править статью" aria-label="Править статью" <a class="btn btn-secondary btn-sm p-2" title="Править статью" aria-label="Править статью"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}"><i class="ico bi bi-pencil" aria-hidden="true"></i></a> th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}"><i class="ico bi bi-pencil" aria-hidden="true"></i></a>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
@@ -73,7 +73,7 @@
</form> </form>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td colspan="2" class="empty">Статей пока нет.</td></tr> <tr th:if="${#lists.isEmpty(section.articles)}"><td th:colspan="${currentUser != null and currentUser.admin} ? 2 : 1" class="empty">Статей пока нет.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -118,7 +118,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="modal-new-article" class="dlg"> <dialog id="modal-new-article" class="dlg" th:if="${currentUser != null and currentUser.admin}">
<div class="dlg-header"> <div class="dlg-header">
<h2 class="dlg-title">Новая статья в разделе</h2> <h2 class="dlg-title">Новая статья в разделе</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button> <button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
@@ -143,7 +143,9 @@
</div> </div>
</dialog> </dialog>
<script th:src="@{/js/content-sort.js(v='36')}" defer></script> <th:block th:if="${currentUser != null and currentUser.admin}">
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
</th:block>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -28,18 +28,17 @@
<div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6"> <div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6">
<table> <table>
<thead> <thead>
<tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th><th>Пропущено</th></tr> <tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="a : ${attestations}"> <tr th:each="a : ${attestations}">
<td th:text="${a.kindLabel}">—</td> <td th:text="${a.kindLabel}">—</td>
<td th:text="${a.semester}">—</td> <td th:text="'Семестр ' + ${a.semester}">—</td>
<td> <td>
<span class="badge" <span class="badge"
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')" th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
th:text="${a.valueLabel}">—</span> th:text="${a.valueLabel}">—</span>
</td> </td>
<td class="text-muted" th:text="${a.missedHours != null ? a.missedHours + ' ч.' : '—'}">—</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,106 @@
package ru.ysu.lms.web.learn
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.session.SessionUser
import java.util.Locale
class LearnSectionTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
@Test
fun `student does not see article admin actions`() {
val html = render("STUDENT")
assertThat(html).contains("Статья 1")
assertThat(html).doesNotContain("Удалить статью")
assertThat(html).doesNotContain("Править статью")
assertThat(html).doesNotContain("Скрыть статью")
assertThat(html).doesNotContain("modal-new-article")
assertThat(html).doesNotContain(">Действия</th>")
}
@Test
fun `admin sees article actions`() {
val html = render("ADMIN")
assertThat(html).contains("Статья 1")
assertThat(html).contains("Удалить статью")
assertThat(html).contains("Править статью")
assertThat(html).contains("Скрыть статью")
assertThat(html).contains("modal-new-article")
assertThat(html).contains(">Действия</th>")
}
private fun render(role: String): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
context.setVariable(
"currentUser",
SessionUser(
id = "user-1",
login = role.lowercase(),
role = role,
name = "Пользователь",
mustChangePassword = false,
),
)
context.setVariable(
"chapter",
mapOf(
"slug" to "chapter-1",
"title" to "Глава",
),
)
context.setVariable(
"section",
mapOf(
"id" to "section-1",
"slug" to "section-1",
"title" to "Раздел",
"published" to true,
"articles" to
listOf(
mapOf(
"id" to "article-1",
"title" to "Статья 1",
"slug" to "article-1",
"published" to true,
),
),
),
)
context.setVariable("back", "/learn/chapter-1")
context.setVariable("backParent", "/learn")
context.setVariable(
"sectionChoices",
listOf(mapOf("id" to "section-2", "chapterId" to "chapter-1", "label" to "Глава / Раздел 2")),
)
return engine.process("learn/section", context)
}
}