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

View File

@@ -30,27 +30,41 @@ class AttestationService(
groupId: UUID,
studentId: UUID,
): List<AttestationResponse> =
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 =

View File

@@ -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<AttestationResultResponse> = emptyList(),
)

View File

@@ -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(),
)

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(
"intermediate" to
mapOf(
"excellent" to "отл",
"satisfactory" to "атт",
"unsatisfactory" to "неуд",
"not_attested" to "не атт",
"excellent" to "отлично",
"satisfactory" to "аттестован",
"unsatisfactory" to "неудовлетворительно",
"not_attested" to "не аттестован",
),
"pass" to
mapOf(
@@ -43,6 +43,22 @@ object AttestationPresentation {
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(
kind: 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.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<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 =
session.getAttribute(SessionKeys.TOKEN) as? String

View File

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

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
@@ -59,6 +60,8 @@ class AdminGroupController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
@GetMapping
fun dashboard(
session: HttpSession,
@@ -143,8 +146,7 @@ class AdminGroupController(
val allLabs = api.listLabs(t)
val group = api.groupDetail(t, id)
val labs = allLabs.filter { it.status == "published" }
val attestations = GroupAttestationViews.columns(api.listAttestations(t, id))
val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations)
val attestations = loadAttestations(t, id)
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 +195,7 @@ class AdminGroupController(
groupViewModel(
t,
id,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters),
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
),
)
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? {
val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
if (scores.isEmpty()) return null
@@ -278,7 +291,6 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
)
private fun groupViewModel(
@@ -303,7 +315,6 @@ class AdminGroupController(
data.labPublication,
submissionCounts,
data.attestations,
data.attestationSemesters,
)
}
@@ -315,7 +326,6 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
)
private fun fillGroupModel(
@@ -329,7 +339,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 })
}

View File

@@ -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,10 @@ data class GroupAttestation(
val id: String = "",
val kind: 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(),
)

View File

@@ -2,38 +2,36 @@ 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<AttestationDto>): List<GroupAttestation> =
attestations.map { column ->
GroupAttestation(
id = column.id,
kind = 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 =
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<LabSummaryDto>,
attestations: List<GroupAttestation>,
): List<String> =
(labs.map { it.semesterTitle } + attestations.map { it.semester })
.filter { it.isNotBlank() && it != "—" }
.distinct()
.sorted()
fun attach(
rows: List<GroupStudentRow>,
columns: List<GroupAttestation>,

View File

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

View File

@@ -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,25 @@ data class AttestationDto(
val id: String = "",
val groupId: String = "",
val kind: String = "",
val semester: String = "",
/** Строка — чтобы старые ответы с текстовым семестром не ломали разбор. */
val semester: String = "1",
val published: Boolean = false,
val results: List<AttestationResultDto> = 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)

View File

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

View File

@@ -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
@@ -95,10 +94,9 @@ class StudentController(
val result = column.results.firstOrNull()
StudentAttestationView(
kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester,
semester = AttestationPresentation.semesterNumber(column.semester),
valueLabel = result?.let { AttestationPresentation.valueLabel(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; }
.score-link {
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;
}
.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); }
.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); }
.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 {
display: inline-flex; align-items: center; justify-content: center;
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; }
.matrix { border-collapse: separate; border-spacing: 0; }
.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);
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.matrix-sticky { background: var(--c-row-hover); }
.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; }
.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); }
#group-table th.average-col,
#group-table th.actions-head { color: var(--c-text); font-weight: 400; }
#group-table th.actions-head,
#group-table td.actions-cell { border-left: 1px solid var(--c-border); }
.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; }
.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-cell .font-mono { margin-top: 0.125rem; }
.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();
}
/* ---------- Аттестация: выбор результата ---------- */
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,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) {
if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize();

View File

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

View File

@@ -16,23 +16,6 @@
</div>
<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="flex items-center justify-between mb-4">
<h2 class="section-title m-0">Группы</h2>
@@ -50,6 +33,23 @@
</table>
</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>
</body>

View File

@@ -16,7 +16,9 @@
</p>
</div>
<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" type="button" data-modal="modal-add-student">+ Студент</button>
</div>
@@ -71,32 +73,51 @@
</div>
</div>
</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>
<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">
<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"
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
method="post"
data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm p-2" type="submit"
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button>
title="Удалить аттестацию" aria-label="Удалить аттестацию">
<i class="cell-ico ico bi bi-trash" aria-hidden="true"></i>
</button>
</form>
</div>
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
<span class="lab-col-title" th:text="${att.semester}">—</span>
</div>
</th>
<th data-nosort>Пароль</th>
<th class="text-right" data-nosort>Действия</th>
<th class="text-right actions-head" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<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 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 : 'без варианта'}">
@@ -138,22 +159,22 @@
</div>
</div>
</td>
<td class="text-center font-mono average-cell"
<td class="average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="text-center attestation-cell">
<button class="score-link" type="button"
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-attestation=${att.id},data-attestation-kind=${att.kind},data-attestation-value=${row.attestations[att.id]?.value},data-missed-hours=${row.attestations[att.id]?.missedHours},data-attestation-label=${att.kindLabel},data-semester=${att.semester}"
data-action="attestation"
th:title="${att.kindLabel} + ' — ' + ${att.semester}">
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span>
<span th:if="${row.attestations[att.id]?.missedHours != null}"
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span>
</button>
</td>
<td class="text-center">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
<td th:each="att : ${attestations}" class="attestation-cell">
<form class="inline-form js-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" th:value="${att.id}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="attestation-select" name="value" data-auto-submit
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
th:title="${row.fullName} + ' — ' + ${att.kindLabel} + ' (' + 'Семестр ' + ${att.semester} + ')'">
<option value="" th:selected="${row.attestations[att.id] == null}">—</option>
<option th:each="opt : ${att.options}" th:value="${opt.value}" th:text="${opt.label}"
th:selected="${row.attestations[att.id]?.value == opt.value}"></option>
</select>
</form>
</td>
<td class="text-right actions-cell">
<div class="actions-col">
@@ -170,7 +191,7 @@
</div>
</td>
</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>
</table>
</div>
@@ -220,48 +241,24 @@
<option value="exam">Экзамен</option>
</select></div>
<div><label class="label" for="attestation-semester">Семестр</label>
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters"
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required>
<datalist id="attestation-semesters">
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option>
</datalist>
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p>
<select class="input" id="attestation-semester" name="semester" required
data-error-required="Укажите семестр от 1 до 8">
<option value="1">Семестр 1</option>
<option value="2">Семестр 2</option>
<option value="3">Семестр 3</option>
<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>
<button class="btn btn-primary" type="submit">Добавить столбец</button>
</form>
</div>
</dialog>
<!-- Диалог: результат аттестации студента -->
<dialog id="modal-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title" id="attestation-title">Результат аттестации</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="attestation-form" method="post"
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="attestationId" id="attestation-id">
<input type="hidden" name="studentId" id="attestation-student">
<div><label class="label">Студент</label>
<input class="input" id="attestation-who" readonly></div>
<div><label class="label" for="attestation-value">Результат</label>
<select class="input" id="attestation-value" name="value">
<option value="">— не выставлено —</option>
</select>
<p class="hint">Оставьте пустым, чтобы убрать результат.</p>
</div>
<div id="attestation-hours-wrap"><label class="label" for="attestation-hours">Пропущено академических часов</label>
<input class="input" id="attestation-hours" name="missedHours" type="number" min="0" step="1"
inputmode="numeric" placeholder="например, 24">
<p class="hint">Укажите для оценки «не атт», если известны пропуски по любой причине.</p>
</div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<!-- Диалог: индивидуальное задание (защита) -->
<!-- Диалог: назначение варианта -->
<dialog id="modal-variant" class="dlg dlg-lg">
@@ -436,7 +433,7 @@
<div class="dlg-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='40')}" defer></script>
<script th:src="@{/js/admin-group.js(v='43')}" defer></script>
</div>
</body>
</html>

View File

@@ -17,7 +17,7 @@
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении 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/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-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
<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/bootstrap.bundle.min.js}" defer></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/form-feedback.js(v='39')}" defer></script>
<script th:src="@{/js/modal-ajax.js(v='1')}" defer></script>

View File

@@ -44,7 +44,7 @@
<div class="card card-flush">
<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>
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<td>
@@ -55,7 +55,7 @@
th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</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="Править статью"
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">
@@ -73,7 +73,7 @@
</form>
</td>
</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>
</table>
</div>
@@ -118,7 +118,7 @@
</div>
</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">
<h2 class="dlg-title">Новая статья в разделе</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
@@ -143,7 +143,9 @@
</div>
</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>
</body>
</html>

View File

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

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