21 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
f7a1830548 feat: add course attestations and average grade column
- store attestation columns and per-student results in progress-service
- add teacher UI for intermediate/pass/exam columns with semester and missed hours
- show average score per student on the group page
- show attestation results on the student dashboard
2026-10-26 16:34:04 +03:00
e22443b08e feat: submit all modal forms without page reload
- add a generic AJAX submitter for forms inside dialogs
- keep modal values and show field errors on server validation failure
- soft-refresh the page content on success, preserving scroll, search and filters
- share toast and re-init hooks for tables, markdown, sections and group page
- require passwords and add minlength/match validation on auth forms
2026-10-23 01:22:05 +03:00
2e43f79256 fix: validate grade input and save modal without reload
- require a score and return a clear message for empty/non-numeric values
- handle unreadable API request bodies instead of falling back to a bare 400
- submit the grade modal via fetch, show errors in place and update the cell
- keep the page scroll position; restore the form feedback tests
2026-10-22 23:31:05 +03:00
d287dd3119 feat: preserve form values and show field errors on submit
- keep submitted values and API field violations across redirects
- reopen the failed modal and highlight invalid fields
- show inline HTML5 validation messages instead of browser bubbles
- keep content-admin errors on the originating page
2026-10-22 22:41:35 +03:00
3827955909 fix: opaque sticky matrix column and square publish marker
Use solid row hover colors so the sticky student column does not show underlying cells while scrolling. Replace the circular checkbox marker with a square one and bump asset version to 36.
2026-10-20 22:03:50 +03:00
53 changed files with 2548 additions and 189 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ build/
.env.prod
/data/
node_modules/
# Локальные сессии Kilo Code — не часть репозитория.
.kilo/
# Каталог с боевыми данными и паролями (не коммитить!)
examples/
__pycache__/

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.common
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@@ -48,6 +49,20 @@ class LmsExceptionHandler {
),
)
/**
* Тело запроса не удалось разобрать: пустая строка вместо числа, битый JSON и т.п.
* Без этого обработчика клиент получал стандартный ответ Spring без detail.
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(): ResponseEntity<ApiError> =
ResponseEntity.badRequest().body(
ApiError(
title = "Bad Request",
status = 400,
detail = "Проверьте формат введённых данных",
),
)
/** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrity(): ResponseEntity<ApiError> =

View File

@@ -20,7 +20,7 @@ spring:
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**,/api/v1/attestations,/api/v1/attestations/**
management:
endpoints:

View File

@@ -0,0 +1,65 @@
package ru.ysu.lms.progress
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class AttestationController(
private val service: AttestationService,
) {
@GetMapping("/attestations")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun list(
@RequestParam groupId: UUID,
): List<AttestationResponse> = service.list(groupId)
@PostMapping("/attestations")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun create(
@RequestBody body: AttestationCreateRequest,
): AttestationResponse = service.create(body)
@DeleteMapping("/attestations/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(
@PathVariable id: UUID,
): Map<String, Boolean> {
service.delete(id)
return mapOf("ok" to true)
}
@PostMapping("/attestations/{id}/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(
@PathVariable id: UUID,
@RequestBody body: AttestationResultRequest,
): AttestationResponse = service.setResult(id, body)
/** Результаты аттестаций текущего студента по его группе. */
@GetMapping("/me/attestations")
fun myAttestations(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: UUID?,
): List<AttestationResponse> {
if (groupId == null) return emptyList()
return service.listForStudent(groupId, UUID.fromString(jwt.subject))
}
}

View File

@@ -0,0 +1,143 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.progress.domain.Attestation
import ru.ysu.lms.progress.domain.AttestationResult
import ru.ysu.lms.progress.domain.AttestationResultKey
import ru.ysu.lms.progress.repo.AttestationRepository
import ru.ysu.lms.progress.repo.AttestationResultRepository
import java.time.Instant
import java.util.UUID
@Service
class AttestationService(
private val attestations: AttestationRepository,
private val results: AttestationResultRepository,
) {
@Transactional(readOnly = true)
fun list(groupId: UUID): List<AttestationResponse> {
val columns = attestations.findByGroupIdOrderByCreatedAtAscIdAsc(groupId)
if (columns.isEmpty()) return emptyList()
val byAttestation = results.findByAttestationIdIn(columns.map { it.id }).groupBy { it.attestationId }
return columns.map { toResponse(it, byAttestation[it.id].orEmpty()) }
}
@Transactional(readOnly = true)
fun listForStudent(
groupId: UUID,
studentId: UUID,
): List<AttestationResponse> =
list(groupId)
.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)
if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8")
val column =
attestations.save(
Attestation(
groupId = UUID.fromString(request.groupId),
kind = kind,
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 =
attestations
.findById(attestationId)
.orElseThrow { NotFoundException("Аттестация не найдена") }
attestations.delete(column)
}
@Transactional
fun setResult(
attestationId: UUID,
request: AttestationResultRequest,
): AttestationResponse {
val column =
attestations
.findById(attestationId)
.orElseThrow { NotFoundException("Аттестация не найдена") }
val studentId = UUID.fromString(request.studentId)
val key = AttestationResultKey(attestationId, studentId)
val value = request.value.trim().lowercase()
val existing = results.findById(key).orElse(null)
if (value.isEmpty()) {
existing?.let { results.delete(it) }
return toResponse(column, results.findByAttestationId(attestationId))
}
val allowed = VALUES[column.kind] ?: emptySet()
if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации")
val entity =
(existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply {
this.value = value
this.updatedAt = Instant.now()
}
results.save(entity)
return toResponse(column, results.findByAttestationId(attestationId))
}
private fun normalizeKind(raw: String): String {
val kind = raw.trim().lowercase()
if (kind !in KINDS) throw BadRequestException("Неизвестный тип аттестации")
return kind
}
private fun toResponse(
column: Attestation,
rows: List<AttestationResult>,
): AttestationResponse =
AttestationResponse(
id = column.id.toString(),
groupId = column.groupId.toString(),
kind = column.kind,
semester = column.semester,
published = column.published,
results =
rows
.sortedBy { it.studentId }
.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 val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM)
private val VALUES =
mapOf(
KIND_INTERMEDIATE to setOf("excellent", "satisfactory", "unsatisfactory", "not_attested"),
KIND_PASS to setOf("passed", "failed", "absent"),
KIND_EXAM to setOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"),
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.io.Serializable
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "attestations")
class Attestation(
@Id var id: UUID = Ids.v7(),
@Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
@Column(nullable = false, length = 16) var kind: String = "intermediate",
@Column(nullable = false) var semester: Int = 1,
@Column(nullable = false) var published: Boolean = false,
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)
@Entity
@Table(name = "attestation_results")
@IdClass(AttestationResultKey::class)
class AttestationResult(
@Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
@Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
@Column(nullable = false, length = 32) var value: String = "",
@Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
)
data class AttestationResultKey(
val attestationId: UUID = UUID(0, 0),
val studentId: UUID = UUID(0, 0),
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -0,0 +1,19 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.Attestation
import ru.ysu.lms.progress.domain.AttestationResult
import ru.ysu.lms.progress.domain.AttestationResultKey
import java.util.UUID
interface AttestationRepository : JpaRepository<Attestation, UUID> {
fun findByGroupIdOrderByCreatedAtAscIdAsc(groupId: UUID): List<Attestation>
}
interface AttestationResultRepository : JpaRepository<AttestationResult, AttestationResultKey> {
fun findByAttestationId(attestationId: UUID): List<AttestationResult>
fun findByAttestationIdIn(attestationIds: Collection<UUID>): List<AttestationResult>
fun findByStudentId(studentId: UUID): List<AttestationResult>
}

View File

@@ -0,0 +1,24 @@
-- Аттестации группы: промежуточная аттестация, зачёт, экзамен.
CREATE TABLE attestations (
id uuid PRIMARY KEY DEFAULT uuidv7(),
group_id uuid NOT NULL,
kind varchar(16) NOT NULL,
semester varchar(100) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT attestations_kind_check CHECK (kind IN ('intermediate', 'pass', 'exam'))
);
CREATE INDEX attestations_group_idx ON attestations (group_id, created_at);
CREATE TABLE attestation_results (
attestation_id uuid NOT NULL REFERENCES attestations (id) ON DELETE CASCADE,
student_id uuid NOT NULL,
value varchar(32) NOT NULL,
missed_hours integer,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (attestation_id, student_id),
CONSTRAINT attestation_results_value_check CHECK (value <> ''),
CONSTRAINT attestation_results_hours_check CHECK (missed_hours IS NULL OR missed_hours >= 0)
);
CREATE INDEX attestation_results_student_idx ON attestation_results (student_id);

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,110 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.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
@Controller
@RequestMapping("/admin")
class AdminAttestationController(
private val api: ApiClient,
) {
@PostMapping("/groups/{id}/attestations")
fun createAttestation(
@PathVariable id: String,
@ModelAttribute form: CreateAttestationForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val semester = form.semester.trim().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),
)
redirect.addFlashAttribute("notice", "Столбец аттестации добавлен")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@PostMapping("/groups/{id}/attestations/{attestationId}/delete")
fun deleteAttestation(
@PathVariable id: String,
@PathVariable attestationId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.deleteAttestation(token(session), attestationId)
redirect.addFlashAttribute("notice", "Столбец аттестации удалён")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@PostMapping("/groups/{id}/attestation-results")
fun setAttestationResult(
@PathVariable id: String,
@ModelAttribute form: SetAttestationResultForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.setAttestationResult(
token(session),
form.attestationId,
AttestationResultPayload(
studentId = form.studentId,
value = form.value,
),
)
redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@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
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

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

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
@@ -38,6 +39,7 @@ import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.labSubmissions
import ru.ysu.lms.web.api.listAttestations
import ru.ysu.lms.web.api.listGrades
import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs
@@ -46,6 +48,8 @@ import ru.ysu.lms.web.api.setGrade
import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.api.suggestComments
import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -56,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,
@@ -113,12 +119,9 @@ class AdminGroupController(
@RequestParam(name = "name", required = false) name: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл со списком группы")
return "redirect:/admin/groups"
}
return try {
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
val result =
api.importGroup(
token(session),
@@ -132,7 +135,6 @@ class AdminGroupController(
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
}
@GetMapping("/groups/{id}")
fun groupDetail(
@@ -141,7 +143,10 @@ class AdminGroupController(
model: Model,
): String {
val t = token(session)
val (group, labs) = loadGroupAndLabs(t, id)
val allLabs = api.listLabs(t)
val group = api.groupDetail(t, id)
val labs = allLabs.filter { it.status == "published" }
val attestations = 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 }
@@ -170,19 +175,29 @@ class AdminGroupController(
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
val rows =
buildRows(
group.students,
labs,
gradeBy,
generalCommentBy,
commentsBy,
publishedByLab,
assignmentsBy,
submissionsBy,
defensesBy,
GroupAttestationViews.attach(
buildRows(
group.students,
labs,
gradeBy,
generalCommentBy,
commentsBy,
publishedByLab,
assignmentsBy,
submissionsBy,
defensesBy,
),
attestations,
)
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy)))
fillGroupModel(
model,
groupViewModel(
t,
id,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
),
)
return "admin/group"
}
@@ -226,6 +241,7 @@ class AdminGroupController(
defenseAssignmentMd = def?.assignmentMd,
)
}
val averageScore = averageScore(cells)
GroupStudentRow(
studentId = s.id,
fullName = s.fullName,
@@ -243,13 +259,30 @@ class AdminGroupController(
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
},
),
averageScore = averageScore,
)
}
private fun loadGroupAndLabs(
/** Аттестации не должны ронять всю ведомость, если сервис ещё не обновлён. */
private fun loadAttestations(
token: String,
id: String,
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
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
return scores
.fold(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
}
private data class GroupRenderData(
val group: GroupDetailDto,
@@ -257,6 +290,7 @@ class AdminGroupController(
val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
)
private fun groupViewModel(
@@ -273,7 +307,15 @@ class AdminGroupController(
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
}
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return GroupViewModel(data.group, data.labs, candidates, data.rows, data.labPublication, submissionCounts)
return GroupViewModel(
data.group,
data.labs,
candidates,
data.rows,
data.labPublication,
submissionCounts,
data.attestations,
)
}
private data class GroupViewModel(
@@ -283,6 +325,7 @@ class AdminGroupController(
val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
)
private fun fillGroupModel(
@@ -295,6 +338,7 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
}
@@ -357,13 +401,16 @@ class AdminGroupController(
redirect: RedirectAttributes,
): String =
try {
val score = form.score.trim()
if (score.isEmpty()) throw ApiException(400, "Укажите оценку")
if (score.toBigDecimalOrNull() == null) throw ApiException(400, "Оценка должна быть числом")
val comments = parseComments(form.commentsJson ?: "[]")
api.setGrade(
token(session),
GradeSetPayload(
studentId = form.studentId,
labId = form.labId,
score = form.score,
score = score,
comments = comments,
generalComment = form.generalComment,
),

View File

@@ -60,12 +60,9 @@ class AdminLabController(
@RequestParam("file") file: MultipartFile,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл")
return "redirect:/admin/labs"
}
return try {
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл")
val result =
api.importLab(
token(session),
@@ -81,7 +78,6 @@ class AdminLabController(
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs"
}
}
@PostMapping("/labs/{id}/toggle-status")
fun toggleStatus(

View File

@@ -49,6 +49,31 @@ data class GroupStudentRow(
val cells: List<GroupLabCell>,
val variantsSummary: String,
val commentsJson: String,
val averageScore: String? = null,
val attestations: Map<String, GroupAttestationResult> = emptyMap(),
)
/** Результат студента по одному столбцу аттестации. */
data class GroupAttestationResult(
val value: String = "",
)
/** Вариант оценки в выпадающем списке аттестации. */
data class AttestationOption(
val value: String = "",
val label: String = "",
)
/** Столбец аттестации на странице группы. */
data class GroupAttestation(
val id: String = "",
val kind: String = "",
val kindLabel: 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

@@ -50,12 +50,9 @@ class ContentAdminController(
@RequestParam(defaultValue = "false") sync: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл")
return "redirect:/admin/content"
}
return try {
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл")
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes, sync)
val modeNote = if (sync) "" else " (режим «только добавить», удаления не было)"
val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
@@ -64,12 +61,11 @@ class ContentAdminController(
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
"статей ${result.articles}, медиафайлов ${result.assets}$removedNote$modeNote",
)
"redirect:/admin/content"
"redirect:/learn"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
"redirect:/learn"
}
}
/** Сохранение порядка при drag & drop (запрос отправляется из JS). */
@PostMapping("/reorder")
@@ -122,7 +118,7 @@ class ContentAdminController(
redirectBack(form.back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/learn"
redirectBack(form.back, "redirect:/learn")
}
@PostMapping("/articles/{id}/save")
@@ -135,10 +131,10 @@ class ContentAdminController(
try {
api.updateContentArticle(token(session), id, form.title, form.bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена")
redirectBack(form.back, "redirect:/admin/content")
redirectBack(form.back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content/articles/$id/edit"
redirectBack(form.back, "redirect:/learn")
}
@PostMapping("/articles/{id}/delete")
@@ -151,10 +147,10 @@ class ContentAdminController(
try {
api.deleteContentArticle(token(session), id)
redirect.addFlashAttribute("notice", "Статья удалена")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/create")
@@ -168,10 +164,10 @@ class ContentAdminController(
try {
api.createChapter(token(session), title.trim(), slug)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/{id}/rename")
@@ -185,10 +181,10 @@ class ContentAdminController(
try {
api.renameChapter(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/{id}/delete")
@@ -201,10 +197,10 @@ class ContentAdminController(
try {
api.deleteChapter(token(session), id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/sections/create")
@@ -216,10 +212,10 @@ class ContentAdminController(
try {
api.createSection(token(session), form.chapterId, form.title.trim(), form.slug)
redirect.addFlashAttribute("notice", "Раздел «${form.title.trim()}» создан")
redirectBack(form.back, "redirect:/admin/content")
redirectBack(form.back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(form.back, "redirect:/learn")
}
@PostMapping("/sections/{id}/rename")
@@ -233,10 +229,10 @@ class ContentAdminController(
try {
api.renameSection(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/sections/{id}/delete")
@@ -255,10 +251,10 @@ class ContentAdminController(
api.deleteSectionWithMove(token(session), id, moveTo)
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
}
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
@PostMapping("/articles/create")
@@ -280,10 +276,10 @@ class ContentAdminController(
),
)
redirect.addFlashAttribute("notice", "Статья «${form.title.trim()}» создана")
redirectBack(form.back, "redirect:/admin/content")
redirectBack(form.back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(form.back, "redirect:/learn")
}
@PostMapping("/articles/{id}/move")
@@ -297,10 +293,10 @@ class ContentAdminController(
try {
api.moveArticle(token(session), id, sectionId)
redirect.addFlashAttribute("notice", "Статья перемещена")
redirectBack(back, "redirect:/admin/content")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
redirectBack(back, "redirect:/learn")
}
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */

View File

@@ -0,0 +1,47 @@
package ru.ysu.lms.web.admin
import ru.ysu.lms.web.AttestationPresentation
import ru.ysu.lms.web.api.AttestationDto
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */
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),
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)
},
)
}
fun attach(
rows: List<GroupStudentRow>,
columns: List<GroupAttestation>,
): List<GroupStudentRow> =
rows.map { row ->
val byColumn = LinkedHashMap<String, GroupAttestationResult>()
for (column in columns) {
val result = column.results[row.studentId] ?: continue
byColumn[column.id] = result
}
row.copy(attestations = byColumn)
}
}

View File

@@ -7,14 +7,15 @@ import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import java.net.URLDecoder
import ru.ysu.lms.common.FieldViolation
import tools.jackson.databind.ObjectMapper
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
@Component
class ApiClient(
@Value("\${lms.gateway.base-url}") baseUrl: String,
private val mapper: ObjectMapper,
) {
// readTimeout большой: через BFF ходят загрузки/выгрузки до 200 МБ (импорт ZIP, архивы сдач).
internal val client: RestClient =
@@ -45,23 +46,64 @@ class ApiClient(
} catch (ex: ApiException) {
throw ex
} catch (ex: RestClientResponseException) {
throw ApiException(ex.statusCode.value(), detail(ex))
} catch (ignore: RestClientException) {
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
val error = parseError(ex)
throw ApiException(ex.statusCode.value(), error.detail, error.violations, ex)
} catch (ex: RestClientException) {
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.", cause = ex)
}
internal fun detail(ex: RestClientResponseException): String {
/** Разбирает ApiError сервиса: detail для тоста и violations для подсветки полей. */
private fun parseError(ex: RestClientResponseException): ParsedError {
val body = ex.responseBodyAsString
val match = DETAIL_PATTERN.find(body)
val parsed =
match
?.groupValues
?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
val fallback = detailFromBody(body)
return try {
val root = mapper.readTree(body)
val rawDetail = root.path("detail").asString("")
val violationsNode = root.path("violations")
val violations =
buildList {
if (violationsNode.isArray) {
violationsNode.forEach { node ->
val field = node.path("field").asString("")
val message = node.path("message").asString("")
if (field.isNotBlank() && message.isNotBlank()) {
add(FieldViolation(field = field, message = message))
}
}
}
}
// Spring-валидация отвечает общим "Request validation failed" — показываем
// конкретные сообщения из violations, а не техническую формулировку.
val detail =
when {
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
fallback != null -> fallback
else -> fallbackDetail(ex.statusCode.value())
}
ParsedError(detail, violations)
} catch (ignore: Exception) {
ParsedError(fallback ?: fallbackDetail(ex.statusCode.value()), emptyList())
}
}
private fun fallbackDetail(status: Int): String = if (status == 400) "Проверьте введённые данные" else "Ошибка запроса ($status)"
private fun detailFromBody(body: String): String? {
val match = DETAIL_PATTERN.find(body)
return match
?.groupValues
?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
?.takeIf { it.isNotBlank() }
}
private data class ParsedError(
val detail: String,
val violations: List<FieldViolation>,
)
internal fun downloadOf(entity: org.springframework.http.ResponseEntity<ByteArray>): DownloadDto {
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" }

View File

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

View File

@@ -264,6 +264,38 @@ data class GradeDto(
val generalComment: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AttestationResultDto(
val studentId: String = "",
val value: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AttestationDto(
val id: String = "",
val groupId: String = "",
val kind: 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: Int,
)
data class AttestationResultPayload(
val studentId: String,
val value: String,
)
data class AttestationPublicationPayload(
val published: Boolean,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto(
val text: String = "",

View File

@@ -1,6 +1,17 @@
package ru.ysu.lms.web.api
import ru.ysu.lms.common.FieldViolation
import ru.ysu.lms.web.form.FormFeedback
class ApiException(
val status: Int,
override val message: String,
) : RuntimeException(message)
val violations: List<FieldViolation> = emptyList(),
cause: Throwable? = null,
) : RuntimeException(message, cause) {
init {
// Пока запрос ещё привязан к потоку, запоминаем введённые значения и ошибки:
// контроллеры ниже превратят это в flash/модель для повторного показа формы.
FormFeedback.capture(this)
}
}

View File

@@ -0,0 +1,38 @@
package ru.ysu.lms.web.config
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.ModelAndView
import org.springframework.web.servlet.support.RequestContextUtils
import ru.ysu.lms.web.form.FormFeedback
import tools.jackson.databind.ObjectMapper
/**
* Переносит снимок неудачной отправки формы на следующую страницу:
* при redirect — во flash, при прямом рендере — в модель.
*/
@Component
class FormFeedbackInterceptor(
private val mapper: ObjectMapper,
) : HandlerInterceptor {
override fun postHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
modelAndView: ModelAndView?,
) {
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback ?: return
request.removeAttribute(FormFeedback.REQUEST_ATTR)
if (modelAndView == null) return
val json = runCatching { mapper.writeValueAsString(feedback) }.getOrNull() ?: return
val viewName = modelAndView.viewName
if (viewName != null && viewName.startsWith("redirect:")) {
RequestContextUtils.getOutputFlashMap(request)?.put(FormFeedback.MODEL_ATTR, json)
} else {
modelAndView.addObject(FormFeedback.MODEL_ATTR, json)
}
}
}

View File

@@ -32,9 +32,11 @@ class WebConfig(
private val adminInterceptor: AdminInterceptor,
private val csrfInterceptor: CsrfInterceptor,
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
private val formFeedbackInterceptor: FormFeedbackInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry.addInterceptor(formFeedbackInterceptor).addPathPatterns("/**")
registry
.addInterceptor(NoCachePageInterceptor())
.addPathPatterns("/**")

View File

@@ -0,0 +1,131 @@
package ru.ysu.lms.web.form
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import ru.ysu.lms.web.api.ApiException
/**
* Снимок неудачной отправки формы: адрес действия, введённые значения и ошибки полей.
*
* Снимок создаётся в момент [ApiException] (см. init в ApiException), кладётся в атрибут
* запроса и затем [ru.ysu.lms.web.config.FormFeedbackInterceptor] переносит его во flash
* при redirect или в модель при прямом рендере страницы. На клиенте form-feedback.js
* находит форму по action, восстанавливает значения, раскрывает модалку и показывает
* ошибки рядом с полями.
*/
data class FormFeedback(
val action: String,
val values: Map<String, List<String>>,
val errors: Map<String, String>,
val general: String?,
) {
companion object {
const val REQUEST_ATTR = "lms.formFeedback"
const val MODEL_ATTR = "formFeedbackJson"
private val IGNORED = setOf("_csrf")
/** Пароли не сохраняем: после ошибки их нужно ввести заново. */
private fun isSensitive(name: String): Boolean {
val n = name.lowercase()
return n.contains("password") || n == "old" || n == "new" || n == "new2"
}
private data class Rule(
val pattern: Regex,
val fields: List<String>,
)
/**
* Сообщения доменных исключений часто не привязаны к полю. Небольшие правила
* помогают показать их рядом с конкретным input, не меняя контракты сервисов.
*/
private val RULES =
listOf(
Rule(Regex("(?iu)логин.*(минимум|символ|пуст)"), listOf("login")),
Rule(Regex("(?iu)(пароли не совпадают|повторите.*парол)"), listOf("password2", "new2")),
Rule(Regex("(?iu)текущ.*парол"), listOf("old")),
Rule(Regex("(?iu)нов.*парол"), listOf("new")),
Rule(Regex("(?iu)парол"), listOf("password", "new")),
Rule(Regex("(?iu)студенческ.*номер"), listOf("studentNo")),
Rule(Regex("(?iu)(студент.*(групп|номер)|студент уже|студент не входит)"), listOf("studentId", "studentNo")),
Rule(Regex("(?iu)фамили"), listOf("lastName")),
Rule(Regex("(?iu)(название группы|группа.*существует)"), listOf("name")),
Rule(Regex("(?iu)номер лабораторной"), listOf("number")),
Rule(Regex("(?iu)номер варианта"), listOf("number")),
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
Rule(Regex("(?iu)оценк"), listOf("score", "value")),
Rule(Regex("(?iu)семестр"), listOf("semester")),
Rule(Regex("(?iu)тип аттестации"), listOf("kind")),
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),
Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")),
Rule(
Regex("(?iu)(приложите файлы|выберите файл|файл.*больш|архив|ожидается .zip|таблиц|столбец)"),
listOf("files", "file"),
),
Rule(Regex("(?iu)код не найден"), listOf("code")),
Rule(Regex("(?iu)главу"), listOf("chapterId", "newChapterTitle", "sectionId")),
Rule(Regex("(?iu)раздел"), listOf("sectionId", "newSectionTitle", "moveTo", "chapterId")),
Rule(Regex("(?iu)задани"), listOf("assignmentMd")),
Rule(Regex("(?iu)(название|заголовок)"), listOf("title", "newChapterTitle", "newSectionTitle")),
)
/** Вызывается из ApiException, пока запрос ещё доступен. */
fun capture(ex: ApiException) {
val request =
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)
?.request
?: return
if (!request.method.equals("POST", ignoreCase = true)) return
val values = LinkedHashMap<String, List<String>>()
runCatching { request.parameterMap }.getOrNull()?.forEach { (name, raw) ->
if (name in IGNORED) return@forEach
// Чувствительные значения не запоминаем, но имя поля оставляем:
// так ошибку можно показать рядом с нужным input.
values[name] = if (isSensitive(name)) emptyList() else raw.toList()
}
val errors = LinkedHashMap<String, String>()
ex.violations.forEach { violation ->
val field = normalizeField(violation.field)
if (field.isNotBlank()) errors.putIfAbsent(field, violation.message)
}
if (errors.isEmpty()) {
inferFields(ex.message, values).forEach { (field, message) -> errors[field] = message }
}
request.setAttribute(
REQUEST_ATTR,
FormFeedback(
action = request.requestURI,
values = values,
errors = errors,
general = ex.message.takeIf { it.isNotBlank() },
),
)
}
private fun normalizeField(raw: String): String = raw.substringAfterLast('.').substringBefore('[').trim()
private fun inferFields(
message: String,
values: Map<String, List<String>>,
): List<Pair<String, String>> {
RULES.forEach { rule ->
if (!rule.pattern.containsMatchIn(message)) return@forEach
val present = rule.fields.filter { values.containsKey(it) }
if (present.isNotEmpty()) {
// У file-полей имя в форме может быть и file, и files — подсветим оба
// варианта, лишний просто не найдётся на странице.
val fields = if (present.any { it == "files" || it == "file" }) present else present.take(1)
return fields.map { it to message }
}
val fileFields = rule.fields.filter { it == "files" || it == "file" }
if (fileFields.isNotEmpty()) return fileFields.map { it to message }
return@forEach
}
return emptyList()
}
}
}

View File

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

View File

@@ -41,6 +41,7 @@ body {
--c-text-link-hover: #9d2b3c;
--c-border: #e6e6e9;
--c-border-strong: #d4d4da;
--c-row-hover: #f4f4f5;
--c-accent: #7a2333;
--c-accent-hover: #5f1b28;
--c-accent-soft: #f7edee;
@@ -71,6 +72,7 @@ body {
--c-text-link-hover: #f0b9c2;
--c-border: #26272e;
--c-border-strong: #34353d;
--c-row-hover: #202127;
--c-accent: #a64d5f;
--c-accent-hover: #b86070;
--c-accent-soft: rgba(166, 77, 95, 0.16);
@@ -141,6 +143,19 @@ p { margin-top: 0; }
.label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; }
.hint { margin-top: 0.25rem; font-size: 0.75rem; color: var(--c-text-muted); }
/* Ошибки формы: поле с ошибкой и сообщение под ним */
.input.is-invalid,
select.is-invalid,
textarea.is-invalid,
input.is-invalid { border-color: var(--c-tone-neg); box-shadow: 0 0 0 3px var(--c-accent-soft); }
input[type="checkbox"].is-invalid,
input[type="radio"].is-invalid { outline: 2px solid var(--c-tone-neg); outline-offset: 2px; }
.field-error { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.35; color: var(--c-tone-neg); }
.form-error-summary {
margin: 0 0 0.25rem; padding: 0.625rem 0.75rem; border: 1px solid var(--c-tone-neg);
background: var(--c-accent-soft); color: var(--c-tone-neg); font-size: 0.8125rem;
}
/* ===== B3-тема поверх Bootstrap: navbar, offcanvas, кнопки, ссылки ===== */
@@ -428,7 +443,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
/* Ячейка матрицы в стиле Б3: вариант и оценка — текстом, подстрока — сдача и защита с точкой */
.cell-stack { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }
.cell-main { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; }
/* Публикация в ячейке — «глаз»-точка: ○ скрыто, ◉ опубликовано (нативный checkbox под капотом) */
/* Публикация в ячейке — квадратный маркер: □ скрыто, ■ опубликовано (нативный checkbox под капотом) */
.pub-toggle {
appearance: none; -webkit-appearance: none;
width: 1rem; height: 1rem; margin: 0; padding: 0; cursor: pointer;
@@ -438,7 +453,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
}
.pub-toggle:checked {
border-color: var(--c-accent);
background: radial-gradient(circle, var(--c-accent) 0 42%, transparent 48%);
background: linear-gradient(var(--c-accent), var(--c-accent)) center / 0.55rem 0.55rem no-repeat;
}
.pub-toggle:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.variant-btn {
@@ -452,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; }
@@ -480,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;
@@ -531,17 +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 tbody tr:hover td { background: rgba(19, 19, 22, 0.03); }
[data-theme="dark"] .matrix tbody tr:hover td { background: rgba(232, 233, 236, 0.045); }
.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); }
#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

@@ -208,43 +208,7 @@ function attachCommentAutocomplete(input) {
});
}
/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */
function showToast(text, kind) {
// уведомления должны быть видны поверх открытой модалки: кладём их в сам <dialog>
// (элемент верхнего слоя), иначе они оказываются под ним
var dlg = document.querySelector('dialog[open]');
var host = null;
if (dlg) {
host = dlg.querySelector('.toast-host');
if (!host) {
host = document.createElement('div');
host.className = 'toast-host';
dlg.appendChild(host);
}
} else {
host = $id('toast-host');
}
if (!host) return;
var toast = document.createElement('div');
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
toast.setAttribute('data-toast', '');
var span = document.createElement('span');
span.className = 'toast-text';
span.textContent = text;
toast.appendChild(span);
var close = document.createElement('button');
close.type = 'button';
close.className = 'toast-close';
close.textContent = '✕';
close.addEventListener('click', function () { toast.remove(); });
toast.appendChild(close);
host.appendChild(toast);
setTimeout(function () { toast.remove(); }, 5000);
}
/* ---------- Удаление подсказки комментария ---------- */
function removeCommentTemplate(text, done) {
var form = $id('grade-form');
@@ -345,6 +309,8 @@ function updateEyeCounts() {
var DEFENSE_STATE = {};
var DEFENSE_POLL_MS = 10000;
var DEFENSE_TICK_MS = 1000;
var DEFENSE_POLL_TIMER = null;
var DEFENSE_TICK_TIMER = null;
function defenseBtn(sessionId) {
if (!sessionId) return null;
@@ -422,6 +388,14 @@ function syncDefenseStatuses(sessions, initial) {
}
function startDefenseWatch() {
if (DEFENSE_POLL_TIMER) {
clearInterval(DEFENSE_POLL_TIMER);
DEFENSE_POLL_TIMER = null;
}
if (DEFENSE_TICK_TIMER) {
clearInterval(DEFENSE_TICK_TIMER);
DEFENSE_TICK_TIMER = null;
}
var meta = $id('defense-meta');
if (!meta) return;
var labs = meta.getAttribute('data-labs') || '';
@@ -442,8 +416,8 @@ function startDefenseWatch() {
}
poll();
setInterval(poll, DEFENSE_POLL_MS);
setInterval(function () {
DEFENSE_POLL_TIMER = setInterval(poll, DEFENSE_POLL_MS);
DEFENSE_TICK_TIMER = setInterval(function () {
Object.keys(DEFENSE_STATE).forEach(function (id) {
var s = DEFENSE_STATE[id];
if (s.status === 'started') applyDefenseToCell(s);
@@ -615,15 +589,211 @@ function focusLab() {
});
}
/* ---------- Оценка: отправка без перезагрузки страницы ----------
* Модалка не закрывается при ошибке, значения остаются на месте,
* позиция прокрутки страницы не меняется. */
function submitGradeForm(form) {
if (form.dataset.saving === '1') return;
form.dataset.saving = '1';
var btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function (html) {
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 student = form.querySelector('[name="studentId"]').value;
var lab = form.querySelector('[name="labId"]').value;
var fresh = doc.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var current = document.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
var errorWithoutFeedback = doc.querySelector('.toast-item.toast-error .toast-text');
if (errorWithoutFeedback) {
showToast(errorWithoutFeedback.textContent.trim(), 'error');
return;
}
if (!fresh && !notice) {
showToast('Не удалось сохранить оценку. Обновите страницу и попробуйте ещё раз.', 'error');
return;
}
if (fresh && current) {
['data-score', 'data-general-comment', 'data-comments', 'title'].forEach(function (attr) {
if (fresh.hasAttribute(attr)) current.setAttribute(attr, fresh.getAttribute(attr));
else current.removeAttribute(attr);
});
var freshLabel = fresh.querySelector('span');
var currentLabel = current.querySelector('span');
if (freshLabel && currentLabel) currentLabel.textContent = freshLabel.textContent;
current.classList.toggle('none', fresh.classList.contains('none'));
}
var openDialog = form.closest('dialog');
if (openDialog) {
var staleToasts = openDialog.querySelector('.toast-host');
if (staleToasts) staleToasts.remove();
}
if (window.closeModal) window.closeModal('modal-grade');
showToast(notice ? notice.textContent.trim() : 'Оценка сохранена', 'success');
})
.catch(function (e) {
showToast('Не удалось сохранить оценку: ' + e.message, 'error');
})
.finally(function () {
form.dataset.saving = '';
if (btn) btn.disabled = false;
});
}
/* ---------- Аттестация: сохранение прямо из ячейки ---------- */
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) {
if (ev.target && ev.target.id === 'grade-form') csSerialize();
var form = ev.target;
if (!form || !form.classList || !form.classList.contains('js-attestation-form')) return;
ev.preventDefault();
submitAttestationForm(form);
}, true);
window.addEventListener('DOMContentLoaded', function () {
/* ---------- Показ/скрытие столбца аттестации ---------- */
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();
ev.preventDefault();
submitGradeForm(ev.target);
}, true);
function initGroupPage() {
focusLab();
startDefenseWatch();
attachPublishToggles();
});
}
window.__lmsInitGroupPage = initGroupPage;
window.addEventListener('DOMContentLoaded', initGroupPage);
/* ---------- Большая модалка «Выдача вариантов» ---------- */
@@ -684,9 +854,49 @@ document.addEventListener('submit', function (ev) {
// flash-сообщение забираем из отданного HTML
var err = /<div[^>]*class="[^"]*toast-item toast-error[^"]*"[^>]*>\s*<span class="toast-text">([^<]*)</.exec(html);
var ok = /<span class="toast-text">([^<]*)<\/span>/.exec(html);
showToast(err ? err[1].trim() : (ok ? ok[1].trim() : 'Сохранено'), err ? 'error' : 'success');
if (err) {
// не перерисовываем модалку: выбранные пользователем значения остаются на месте
showToast(err[1].trim(), 'error');
return;
}
showToast(ok ? ok[1].trim() : 'Сохранено', 'success');
return refreshAssignModal(form.getAttribute('data-refresh'));
})
.catch(function (e) { showToast('Не удалось сохранить: ' + e.message, 'error'); })
.finally(function () { if (btn) btn.disabled = false; });
});
/* После возврата формы оценки с ошибкой сервера восстанавливаем подпись студента
и динамические строки комментариев (значения уже подставлены form-feedback.js). */
document.addEventListener('lms:form-restored', function (event) {
var form = event.detail && event.detail.form;
if (!form || form.id !== 'grade-form') return;
var student = $id('grade-student');
var lab = $id('grade-lab');
if (!student || !lab) return;
var btn = document.querySelector(
'.score-link[data-student="' + student.value + '"][data-lab="' + lab.value + '"]'
);
if (btn) {
var who = $id('grade-who');
if (who) who.value = btn.getAttribute('data-student-name') || '';
var title = $id('grade-title');
if (title) {
title.textContent = (btn.getAttribute('data-lab-name') || '') +
' — ' + (btn.getAttribute('data-student-name') || '');
}
}
var raw = $id('grade-comments');
var list = [];
try { list = raw ? JSON.parse(raw.value || '[]') : []; } catch (e) { list = []; }
var box = $id('cs-rows');
if (!box) return;
box.innerHTML = '';
(Array.isArray(list) ? list : []).forEach(function (comment) {
csRow(comment.text || '', comment.tone || 'neutral');
});
if (!list.length) csRow('', 'neutral');
});

View File

@@ -19,4 +19,9 @@
if (kind === 'lab-edit') setMode(true);
if (kind === 'lab-edit-cancel') setMode(false);
});
/* Форма вернулась с ошибкой сервера — сразу открываем режим редактирования. */
document.addEventListener('lms:form-restored', function (e) {
if (e.detail && e.detail.form && e.detail.form.id === 'lab-edit-form') setMode(true);
});
})();

View File

@@ -75,6 +75,7 @@
function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
window.__lmsInitSections = initSections;
window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc');
if (!acc) return;
@@ -114,6 +115,39 @@
});
}
/* Программные уведомления: показываем поверх открытой модалки, если она есть. */
window.showToast = function (text, kind) {
var dialog = document.querySelector('dialog[open]');
var host = null;
if (dialog) {
host = dialog.querySelector('.toast-host');
if (!host) {
host = document.createElement('div');
host.className = 'toast-host';
dialog.appendChild(host);
}
} else {
host = document.getElementById('toast-host');
}
if (!host) return;
var toast = document.createElement('div');
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
toast.setAttribute('data-toast', '');
var textEl = document.createElement('span');
textEl.className = 'toast-text';
textEl.textContent = text;
toast.appendChild(textEl);
var close = document.createElement('button');
close.type = 'button';
close.className = 'toast-close';
close.textContent = '✕';
close.addEventListener('click', function () { toast.remove(); });
toast.appendChild(close);
host.appendChild(toast);
setTimeout(function () { toast.remove(); }, 5000);
};
document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll();

View File

@@ -0,0 +1,352 @@
/* Формы: восстановление введённых значений после ошибки сервера и показ ошибок
рядом с конкретными полями. Сервер кладёт снимок в #form-feedback (data-feedback),
форма находится по action; модалка с этой формой открывается автоматически. */
(function () {
'use strict';
var FEEDBACK_ID = 'form-feedback';
var INVALID = 'is-invalid';
var FIELD_ERROR = 'field-error';
var SUMMARY = 'form-error-summary';
function controls(form, name) {
var out = [];
for (var i = 0; i < form.elements.length; i++) {
if (form.elements[i].name === name) out.push(form.elements[i]);
}
return out;
}
function normalizePath(path) {
return path.length > 1 ? path.replace(/\/+$/, '') : path;
}
function actionPath(form) {
var action = form.getAttribute('action') || location.href;
try {
return normalizePath(new URL(action, location.href).pathname);
} catch (e) {
return normalizePath(action);
}
}
function sameValues(a, b) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) {
if (String(a[i]) !== String(b[i])) return false;
}
return true;
}
function currentValues(els) {
var out = [];
els.forEach(function (el) {
if (el.type === 'checkbox' || el.type === 'radio') {
if (el.checked) out.push(el.value);
} else if (el.tagName === 'SELECT' && el.multiple) {
for (var i = 0; i < el.options.length; i++) {
if (el.options[i].selected) out.push(el.options[i].value);
}
} else {
out.push(el.value);
}
});
return out;
}
function hasFileInput(form) {
for (var i = 0; i < form.elements.length; i++) {
if (form.elements[i].type === 'file') return true;
}
return false;
}
/* Среди форм с тем же action выбираем ту, чьи значения похожи на отправленные:
так различаются, например, модалки «добавить вариант» и «править вариант». */
function formScore(form, values, errors) {
var score = 0;
var visibleMatches = 0;
Object.keys(values || {}).forEach(function (name) {
var els = controls(form, name);
if (!els.length) return;
var hidden = els.every(function (el) { return el.type === 'hidden'; });
if (sameValues(currentValues(els), values[name] || [])) {
score += hidden ? 2 : 1;
if (!hidden) visibleMatches++;
}
});
var errorMatches = 0;
Object.keys(errors || {}).forEach(function (name) {
if (controls(form, name).length) errorMatches++;
});
return { score: score, visibleMatches: visibleMatches, errorMatches: errorMatches };
}
function hasEditableControl(form) {
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (el.disabled) continue;
if (el.tagName === 'BUTTON' || el.type === 'hidden' || el.type === 'submit' ||
el.type === 'button' || el.type === 'reset' || el.type === 'image') continue;
return true;
}
return false;
}
function findForm(feedback) {
var forms = document.querySelectorAll('form');
var target = normalizePath(feedback.action || '');
var best = null;
var bestScore = -1;
for (var i = 0; i < forms.length; i++) {
var form = forms[i];
if ((form.getAttribute('method') || 'get').toLowerCase() !== 'post') continue;
if (actionPath(form) !== target) continue;
if (!hasEditableControl(form)) continue;
var match = formScore(form, feedback.values, feedback.errors);
// Не открываем случайную форму с тем же action, если в ней нет ни одного
// совпавшего значения или поля с ошибкой. Исключение — загрузка файла:
// файл нельзя восстановить, но модалку с ошибкой показать нужно.
var acceptable = match.visibleMatches > 0 || match.errorMatches > 0 ||
(hasFileInput(form) && !!feedback.general);
if (!acceptable) continue;
if (match.score > bestScore) {
best = form;
bestScore = match.score;
}
}
return best;
}
function restoreValues(form, values) {
Object.keys(values || {}).forEach(function (name) {
var els = controls(form, name);
if (!els.length) return;
var stored = values[name] || [];
els.forEach(function (el, index) {
if (el.type === 'file' || el.type === 'password' || el.disabled) return;
if (el.type === 'checkbox') {
el.checked = stored.indexOf(el.value) >= 0 ||
(el.value === 'on' && stored.length > 0);
} else if (el.type === 'radio') {
el.checked = stored.indexOf(el.value) >= 0;
} else if (el.tagName === 'SELECT' && el.multiple) {
for (var i = 0; i < el.options.length; i++) {
el.options[i].selected = stored.indexOf(el.options[i].value) >= 0;
}
} else {
var value = els.length > 1
? (stored[index] !== undefined ? stored[index] : '')
: (stored[0] || '');
if (el.tagName === 'SELECT' && !hasOption(el, value)) return;
el.value = value;
}
});
});
}
function hasOption(select, value) {
for (var i = 0; i < select.options.length; i++) {
if (select.options[i].value === value) return true;
}
return false;
}
function errorHost(el) {
if (el.type === 'checkbox' || el.type === 'radio') {
return el.closest('label') || el;
}
return el;
}
function clearFieldErrors(form, name) {
if (!form) return;
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) {
if (node.getAttribute('data-field') === name) node.remove();
});
controls(form, name).forEach(function (el) {
el.classList.remove(INVALID);
el.removeAttribute('aria-invalid');
});
}
function showFieldError(el, message) {
var form = el.form;
if (form) clearFieldErrors(form, el.name);
el.classList.add(INVALID);
el.setAttribute('aria-invalid', 'true');
var error = document.createElement('p');
error.className = FIELD_ERROR;
error.setAttribute('data-field', el.name);
error.textContent = message;
errorHost(el).insertAdjacentElement('afterend', error);
}
function showSummary(form, message) {
var old = form.querySelector('.' + SUMMARY);
if (old) old.remove();
if (!message) return;
var box = document.createElement('div');
box.className = SUMMARY;
box.setAttribute('role', 'alert');
box.textContent = message;
form.insertBefore(box, form.firstChild);
}
function clearAllErrors(form) {
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) { node.remove(); });
form.querySelectorAll('.' + INVALID).forEach(function (el) {
el.classList.remove(INVALID);
el.removeAttribute('aria-invalid');
});
var summary = form.querySelector('.' + SUMMARY);
if (summary) summary.remove();
}
function focusFirstInvalid(form) {
var el = form.querySelector('.' + INVALID);
if (!el || typeof el.focus !== 'function') return;
try {
el.focus({ preventScroll: true });
} catch (e) {
el.focus();
}
if (el.scrollIntoView) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
function parseFeedback() {
var holder = document.getElementById(FEEDBACK_ID);
if (!holder) return null;
var raw = holder.getAttribute('data-feedback');
holder.remove();
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function applyFeedback(feedback) {
if (!feedback || !feedback.action) return;
var form = findForm(feedback);
if (!form) return;
restoreValues(form, feedback.values);
clearAllErrors(form);
if (form.hidden) form.hidden = false;
var dialog = form.closest('dialog');
if (dialog && !dialog.open) {
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
var errors = feedback.errors || {};
Object.keys(errors).forEach(function (name) {
var els = controls(form, name);
if (els.length) showFieldError(els[0], errors[name]);
});
showSummary(form, feedback.general);
document.dispatchEvent(new CustomEvent('lms:form-restored', {
detail: { form: form, feedback: feedback }
}));
setTimeout(function () { focusFirstInvalid(form); }, 0);
}
/* Встроенная валидация: вместо браузерных пузырей — сообщение под полем. */
function symbolWord(count) {
var last = count % 10;
var lastTwo = count % 100;
if (last === 1 && lastTwo !== 11) return 'символ';
if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'символа';
return 'символов';
}
function validationMessage(el) {
var v = el.validity || {};
if (v.valueMissing) {
var requiredMessage = el.getAttribute('data-error-required');
if (requiredMessage) return requiredMessage;
if (el.type === 'file') return 'Выберите файл';
if (el.tagName === 'SELECT') return 'Выберите значение';
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
return 'Заполните поле';
}
if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат';
if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат';
if (v.tooShort) return 'Минимум ' + el.minLength + ' ' + symbolWord(el.minLength);
if (v.tooLong) return 'Максимум ' + el.maxLength + ' ' + symbolWord(el.maxLength);
if (v.rangeUnderflow) return 'Значение не меньше ' + el.min;
if (v.rangeOverflow) return 'Значение не больше ' + el.max;
if (v.stepMismatch) return 'Недопустимое значение';
if (v.badInput) return 'Введите число';
return el.validationMessage || 'Проверьте значение';
}
document.addEventListener('invalid', function (event) {
var el = event.target;
if (!el || !el.form) return;
event.preventDefault();
showFieldError(el, validationMessage(el));
if (!el.form.querySelector('.' + SUMMARY)) showSummary(el.form, 'Проверьте выделенные поля');
setTimeout(function () { focusFirstInvalid(el.form); }, 0);
}, true);
function clearOnEdit(el) {
if (!el || !el.form || !el.name) return;
clearFieldErrors(el.form, el.name);
if (!el.form.querySelector('.' + INVALID)) {
var summary = el.form.querySelector('.' + SUMMARY);
if (summary) summary.remove();
}
}
/* Проверка совпадения значений (например, повтор пароля). */
function checkMatch(el) {
var selector = el.getAttribute('data-match');
if (!selector) return;
var other = null;
try {
other = document.querySelector(selector);
} catch (e) {
return;
}
if (!other) return;
var message = el.getAttribute('data-error-match') || 'Значения не совпадают';
if (el.value && other.value && el.value !== other.value) {
el.setCustomValidity(message);
} else {
el.setCustomValidity('');
}
}
function recheckMatches(source) {
document.querySelectorAll('[data-match]').forEach(function (el) {
try {
if (source.matches(el.getAttribute('data-match'))) checkMatch(el);
} catch (e) { /* некорректный селектор в разметке — пропускаем */ }
});
}
document.addEventListener('input', function (event) {
checkMatch(event.target);
recheckMatches(event.target);
clearOnEdit(event.target);
}, true);
document.addEventListener('change', function (event) {
checkMatch(event.target);
recheckMatches(event.target);
clearOnEdit(event.target);
}, true);
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
window.lmsApplyFormFeedback = applyFeedback;
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-match]').forEach(checkMatch);
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
});
})();

View File

@@ -70,6 +70,7 @@
function ready() {
document.querySelectorAll('.md-body pre').forEach(enhance);
}
window.__lmsInitMdContent = ready;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready);

View File

@@ -140,4 +140,11 @@
document.addEventListener('DOMContentLoaded', function () {
if (window.location.search.indexOf('edit=1') >= 0) window.startArticleEdit();
});
/* Форма вернулась с ошибкой сервера — сразу открываем режим правки. */
document.addEventListener('lms:form-restored', function (event) {
if (event.detail && event.detail.form && event.detail.form.id === 'article-edit') {
window.startArticleEdit();
}
});
})();

View File

@@ -0,0 +1,227 @@
/* Формы внутри модалок: отправка через fetch без перезагрузки страницы.
Ошибка показывается в самой форме, успех — закрывает модалку и мягко обновляет
содержимое страницы (позиция прокрутки и фильтры сохраняются). */
(function () {
'use strict';
function hasEditableControl(form) {
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (el.disabled) continue;
if (el.tagName === 'BUTTON' || el.type === 'hidden' || el.type === 'submit' ||
el.type === 'button' || el.type === 'reset' || el.type === 'image') continue;
return true;
}
return false;
}
function isModalForm(form) {
if (!form || !form.closest('dialog')) return false;
if (form.classList.contains('js-assign-form')) return false;
if (form.id === 'grade-form') return false; // у оценки своя быстрая AJAX-логика
if (form.hasAttribute('data-no-ajax')) return false;
if ((form.getAttribute('method') || 'post').toLowerCase() !== 'post') return false;
return hasEditableControl(form);
}
function currentScrollY() {
if (typeof window.__pageScrollY === 'number') return window.__pageScrollY;
return window.scrollY || window.pageYOffset || 0;
}
function closeFormDialog(form) {
var dialog = form.closest('dialog');
if (dialog) {
if (window.closeModal && dialog.id) {
window.closeModal(dialog.id);
} else {
dialog.close();
}
}
// На всякий случай закрываем и остальные диалоги, чтобы не остался scroll-lock.
document.querySelectorAll('dialog[open]').forEach(function (open) {
if (open === dialog) return;
if (window.closeModal && open.id) window.closeModal(open.id);
else open.close();
});
}
function captureUiState() {
var searches = [];
document.querySelectorAll('input.table-search').forEach(function (input, index) {
searches.push({ index: index, value: input.value });
});
var filters = [];
document.querySelectorAll('table.tablekit').forEach(function (table, index) {
var values = [];
table.querySelectorAll('.filter-control').forEach(function (control) {
values.push(control.value);
});
filters.push({ index: index, values: values });
});
var labFocus = document.getElementById('lab-focus');
return {
searches: searches,
filters: filters,
labFocus: labFocus ? labFocus.value : null
};
}
function restoreUiState(state) {
if (state.labFocus != null) {
var focus = document.getElementById('lab-focus');
if (focus) focus.value = state.labFocus;
}
var searches = document.querySelectorAll('input.table-search');
state.searches.forEach(function (item) {
var input = searches[item.index];
if (!input) return;
input.value = item.value;
input.dispatchEvent(new Event('input', { bubbles: true }));
});
var tables = document.querySelectorAll('table.tablekit');
state.filters.forEach(function (item) {
var table = tables[item.index];
if (!table) return;
var controls = table.querySelectorAll('.filter-control');
item.values.forEach(function (value, index) {
var control = controls[index];
if (!control) return;
control.value = value;
control.dispatchEvent(new Event(control.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
});
});
}
function initContentModules() {
if (window.__lmsInitTableScroll) window.__lmsInitTableScroll();
if (window.__lmsInitTablekit) window.__lmsInitTablekit();
if (window.__lmsInitMdContent) window.__lmsInitMdContent();
if (window.__lmsInitSections) window.__lmsInitSections();
if (window.__hljsAll) window.__hljsAll();
}
function initPageSpecific() {
if (window.__lmsInitGroupPage) window.__lmsInitGroupPage();
if (window.startArticleEdit && location.search.indexOf('edit=1') >= 0) {
window.startArticleEdit();
}
}
function applyServerError(form, doc, feedbackNode) {
var feedback = null;
if (feedbackNode) {
try {
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
} catch (e) {
feedback = null;
}
}
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
window.lmsApplyFormFeedback(feedback);
}
var dialog = form.closest('dialog');
if (dialog) {
var oldToasts = dialog.querySelector('.toast-host');
if (oldToasts) oldToasts.remove();
}
var toast = doc.querySelector('.toast-item.toast-error .toast-text');
window.showToast(
toast
? toast.textContent.trim()
: ((feedback && feedback.general) || 'Не удалось сохранить изменения'),
'error'
);
}
function refreshSamePage(form, doc, target, scrollY, successText) {
var main = document.querySelector('main');
var nextMain = doc.querySelector('main');
if (!main || !nextMain) {
closeFormDialog(form);
window.location.assign(target.href);
return;
}
var uiState = captureUiState();
closeFormDialog(form);
main.innerHTML = nextMain.innerHTML;
document.title = doc.title;
if (location.pathname === target.pathname && location.search !== target.search) {
history.replaceState(history.state, '', target.href);
}
initContentModules();
restoreUiState(uiState);
initPageSpecific();
window.scrollTo(0, scrollY);
requestAnimationFrame(function () { window.scrollTo(0, scrollY); });
window.showToast(successText || 'Сохранено', 'success');
}
function send(form) {
if (form.dataset.ajaxSaving === '1') return;
form.dataset.ajaxSaving = '1';
var buttons = form.querySelectorAll('button[type="submit"], input[type="submit"]');
buttons.forEach(function (button) { button.disabled = true; });
var scrollY = currentScrollY();
fetch(form.action, {
method: (form.getAttribute('method') || 'post').toUpperCase(),
body: new FormData(form),
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text().then(function (html) {
return { html: html, url: response.url };
});
})
.then(function (response) {
var doc = new DOMParser().parseFromString(response.html, 'text/html');
var target = new URL(response.url, location.href);
var feedbackNode = doc.getElementById('form-feedback');
if (feedbackNode) {
applyServerError(form, doc, feedbackNode);
return;
}
// Ошибка без формы (например, истекла сессия) или успех с переходом
// на другую страницу — обычная навигация.
if (target.pathname !== location.pathname) {
closeFormDialog(form);
window.location.assign(target.href);
return;
}
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
if (errorToast) {
window.showToast(errorToast.textContent.trim(), 'error');
return;
}
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
var successText = notice ? notice.textContent.trim() : (form.getAttribute('data-success') || '');
refreshSamePage(form, doc, target, scrollY, successText);
})
.catch(function (error) {
window.showToast('Не удалось сохранить изменения: ' + error.message, 'error');
})
.finally(function () {
form.dataset.ajaxSaving = '';
buttons.forEach(function (button) { button.disabled = false; });
});
}
document.addEventListener('submit', function (event) {
var form = event.target;
if (!isModalForm(form)) return;
if (event.defaultPrevented) return;
event.preventDefault();
send(form);
}, true);
})();

View File

@@ -14,6 +14,7 @@
function ready() {
document.querySelectorAll('table').forEach(wrap);
}
window.__lmsInitTableScroll = ready;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready);

View File

@@ -192,8 +192,15 @@
});
}
document.addEventListener('DOMContentLoaded', function () {
function init() {
document.querySelectorAll('table.tablekit').forEach(bind);
document.querySelectorAll('input.table-search').forEach(bindSearch);
});
}
window.__lmsInitTablekit = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

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,10 @@
</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>
</div>
@@ -30,10 +33,10 @@
</select>
</div>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x">
<table class="matrix tablekit" id="group-table">
<thead>
@@ -70,15 +73,51 @@
</div>
</div>
</th>
<th data-nosort>Пароль</th>
<th class="text-right" data-nosort>Действия</th>
<th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
<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="Удалить аттестацию">
<i class="cell-ico ico bi bi-trash" aria-hidden="true"></i>
</button>
</form>
</div>
</div>
</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 : 'без варианта'}">
@@ -120,9 +159,22 @@
</div>
</div>
</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 class="average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<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">
@@ -139,7 +191,7 @@
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 3}">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
@@ -159,7 +211,8 @@
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1" required
data-error-required="Укажите оценку"></div>
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
<textarea class="input" id="grade-general" name="generalComment" rows="4"
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
@@ -172,6 +225,40 @@
</div>
</dialog>
<!-- Диалог: добавление столбца аттестации -->
<dialog id="modal-add-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая аттестация</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" method="post" th:action="@{/admin/groups/{id}/attestations(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="attestation-kind">Форма аттестации</label>
<select class="input" id="attestation-kind" name="kind" required>
<option value="intermediate">Промежуточная аттестация</option>
<option value="pass">Зачёт</option>
<option value="exam">Экзамен</option>
</select></div>
<div><label class="label" for="attestation-semester">Семестр</label>
<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-variant" class="dlg dlg-lg">
@@ -277,7 +364,8 @@
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p>
<button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
</form>
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post"
data-success="Студент создан. Пароль показан ниже.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="row g-3">
<div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label>
@@ -345,7 +433,7 @@
<div class="dlg-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='35')}" defer></script>
<script th:src="@{/js/admin-group.js(v='43')}" defer></script>
</div>
</body>
</html>

View File

@@ -196,7 +196,7 @@
</dialog>
</th:block>
</div>
<script th:src="@{/js/admin-lab.js(v='35')}" defer></script>
<script th:src="@{/js/admin-lab.js(v='37')}" defer></script>
</div>
</body>
</html>

View File

@@ -14,8 +14,8 @@
</p>
<form class="card form-card form-stack" th:action="@{/change-password}" method="post">
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" autocomplete="current-password" required></div>
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" autocomplete="new-password" required></div>
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" autocomplete="new-password" minlength="8" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" autocomplete="new-password" minlength="8" data-match="#new" data-error-match="Пароли не совпадают" required></div>
<button class="btn btn-primary" type="submit">Сменить пароль</button>
<a th:if="${currentUser != null and !currentUser.mustChangePassword}"
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>

View File

@@ -13,22 +13,24 @@
<noscript>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</noscript>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg(v='35')}">
<!-- v=35: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg(v='36')}">
<!-- 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='35')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='35')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='35')}" disabled>
<script th:src="@{/js/highlight.min.js(v='35')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='35')}" defer></script>
<script th:src="@{/js/tablekit.js(v='35')}" defer></script>
<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>
<script th:src="@{/js/table-scroll.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/theme-init.js(v='35')}"></script>
<script th:src="@{/js/app.js(v='35')}" defer></script>
<script th:src="@{/js/ui-state.js(v='35')}" defer></script>
<script th:src="@{/js/md-content.js(v='35')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='35')}" defer></script>
<script th:src="@{/js/theme-init.js(v='36')}"></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>
<script th:src="@{/js/md-content.js(v='38')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
</head>
<body>
<div class="page d-flex flex-column min-vh-100">
@@ -77,6 +79,7 @@
LMS v1.1.0
</footer>
</div>
<div id="form-feedback" hidden th:if="${formFeedbackJson != null}" th:attr="data-feedback=${formFeedbackJson}"></div>
<div class="toast-host" id="toast-host" aria-live="polite">
<div th:if="${notice}" class="toast-item toast-success" data-toast>
<span class="toast-text" th:text="${notice}">…</span>

View File

@@ -138,7 +138,7 @@
</div>
<script th:src="@{/js/mdeditor.js(v='35')}" defer></script>
<script th:src="@{/js/mdeditor.js(v='37')}" defer></script>
</div>
</body>
</html>

View File

@@ -183,7 +183,7 @@
</div>
</dialog>
<script th:src="@{/js/content-sort.js(v='35')}" defer></script>
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
</div>
</body>
</html>

View File

@@ -236,8 +236,8 @@
</div>
</dialog>
<script th:src="@{/js/mdeditor.js(v='35')}" defer></script>
<script th:src="@{/js/content-sort.js(v='35')}" defer></script>
<script th:src="@{/js/mdeditor.js(v='37')}" defer></script>
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
</div>
</body>
</html>

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='35')}" 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

@@ -7,9 +7,9 @@
<h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<form class="card form-card form-stack" th:action="@{/setup}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" autocomplete="new-password" required></div>
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" minlength="3" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="new-password" minlength="8" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" autocomplete="new-password" minlength="8" data-match="#password" data-error-match="Пароли не совпадают" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>

View File

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

View File

@@ -93,6 +93,7 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input class="input w-260" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}"
data-error-pattern="Введите 6 цифр"
inputmode="numeric" autocomplete="one-time-code" required>
<button class="btn btn-primary" type="submit">Приступить</button>
</form>
@@ -111,7 +112,7 @@
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
</div>
</div>
<script th:src="@{/js/defense-timer.js(v='35')}" defer></script>
<script th:src="@{/js/defense-timer.js(v='36')}" defer></script>
</div>
</body>
</html>

View File

@@ -0,0 +1,51 @@
package ru.ysu.lms.web.config
import org.hamcrest.Matchers.notNullValue
import org.junit.jupiter.api.Test
import org.springframework.stereotype.Controller
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import org.springframework.web.servlet.support.SessionFlashMapManager
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.form.FormFeedback
import tools.jackson.databind.ObjectMapper
class FormFeedbackFlashTest {
@Controller
private class SubmitController {
@PostMapping("/submit")
fun submit(
@RequestParam title: String,
redirect: RedirectAttributes,
): String {
// Контроллер поймал ошибку сервиса и возвращает пользователя на страницу.
val message = "Не удалось сохранить «$title»"
ApiException(400, message)
redirect.addFlashAttribute("error", message)
return "redirect:/done"
}
}
@Test
fun `feedback survives redirect through flash map`() {
val mockMvc =
MockMvcBuilders
.standaloneSetup(SubmitController())
.addInterceptors(FormFeedbackInterceptor(ObjectMapper()))
.setFlashMapManager(SessionFlashMapManager())
.build()
mockMvc
.post("/submit") { param("title", "Лабораторная") }
.andExpect {
status { is3xxRedirection() }
redirectedUrl("/done")
flash {
attribute(FormFeedback.MODEL_ATTR, notNullValue())
}
}
}
}

View File

@@ -0,0 +1,49 @@
package ru.ysu.lms.web.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.web.servlet.DispatcherServlet
import org.springframework.web.servlet.FlashMap
import org.springframework.web.servlet.ModelAndView
import ru.ysu.lms.web.form.FormFeedback
import tools.jackson.databind.ObjectMapper
class FormFeedbackInterceptorTest {
private val mapper = ObjectMapper()
private val interceptor = FormFeedbackInterceptor(mapper)
private val feedback =
FormFeedback(
action = "/admin/labs/create",
values = mapOf("title" to listOf("Лабораторная")),
errors = mapOf("title" to "Укажите название"),
general = "Проверьте поля",
)
@Test
fun `puts feedback into flash map on redirect`() {
val request = MockHttpServletRequest("POST", "/admin/labs/create")
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
val flashMap = FlashMap()
request.setAttribute(DispatcherServlet.OUTPUT_FLASH_MAP_ATTRIBUTE, flashMap)
interceptor.postHandle(request, MockHttpServletResponse(), Any(), ModelAndView("redirect:/admin/labs"))
val json = flashMap[FormFeedback.MODEL_ATTR] as? String
assertNotNull(json)
assertEquals("/admin/labs/create", mapper.readTree(json).path("action").asString(""))
}
@Test
fun `puts feedback into model on direct render`() {
val request = MockHttpServletRequest("POST", "/login")
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
val modelAndView = ModelAndView("login")
interceptor.postHandle(request, MockHttpServletResponse(), Any(), modelAndView)
assertNotNull(modelAndView.model[FormFeedback.MODEL_ATTR])
}
}

View File

@@ -0,0 +1,74 @@
package ru.ysu.lms.web.form
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import ru.ysu.lms.common.FieldViolation
import ru.ysu.lms.web.api.ApiException
class FormFeedbackTest {
@AfterEach
fun tearDown() {
RequestContextHolder.resetRequestAttributes()
}
private fun request(vararg params: Pair<String, String>): MockHttpServletRequest =
MockHttpServletRequest("POST", "/admin/labs/create").apply {
params.forEach { (name, value) -> addParameter(name, value) }
}
@Test
fun `captures values and field violations`() {
val request = request("title" to "Лабораторная", "number" to "0", "_csrf" to "token")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Request validation failed", listOf(FieldViolation("title", "Укажите название")))
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertNotNull(feedback)
assertEquals("/admin/labs/create", feedback!!.action)
assertEquals(listOf("Лабораторная"), feedback.values["title"])
assertEquals("Укажите название", feedback.errors["title"])
assertFalse(feedback.values.containsKey("_csrf"))
}
@Test
fun `does not keep passwords`() {
val request = request("login" to "admin", "password" to "secret")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(401, "Неверный логин или пароль")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals(listOf("admin"), feedback!!.values["login"])
assertEquals(emptyList<String>(), feedback.values["password"])
}
@Test
fun `infers field from domain message`() {
val request = request("score" to "7")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Оценка должна быть в диапазоне 0–5")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals("Оценка должна быть в диапазоне 0–5", feedback!!.errors["score"], feedback.toString())
}
@Test
fun `marks both file field names`() {
val request = request()
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Выберите файл")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals("Выберите файл", feedback!!.errors["file"])
assertEquals("Выберите файл", feedback.errors["files"])
}
}

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