Compare commits

...

3 Commits

Author SHA1 Message Date
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
36 changed files with 1566 additions and 87 deletions

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

View File

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

View File

@@ -163,6 +163,34 @@ data class GradeResponse(
val generalComment: String = "",
)
data class AttestationCreateRequest(
val groupId: String,
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
val kind: String,
val semester: String,
)
data class AttestationResultRequest(
val studentId: String,
/** Пустое значение очищает результат. */
val value: String = "",
val missedHours: Int? = null,
)
data class AttestationResultResponse(
val studentId: String,
val value: String,
val missedHours: Int? = null,
)
data class AttestationResponse(
val id: String,
val groupId: String,
val kind: String,
val semester: String,
val results: List<AttestationResultResponse> = emptyList(),
)
data class SubmissionDto(
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, length = 100) var semester: String = "",
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)
@Entity
@Table(name = "attestation_results")
@IdClass(AttestationResultKey::class)
class AttestationResult(
@Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
@Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
@Column(nullable = false, length = 32) var value: String = "",
@Column(name = "missed_hours") var missedHours: Int? = null,
@Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
)
data class AttestationResultKey(
val attestationId: UUID = UUID(0, 0),
val studentId: UUID = UUID(0, 0),
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.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 +47,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
@@ -137,7 +140,11 @@ 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 = GroupAttestationViews.columns(api.listAttestations(t, id))
val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations)
val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -166,19 +173,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, attestationSemesters),
),
)
return "admin/group"
}
@@ -222,6 +239,7 @@ class AdminGroupController(
defenseAssignmentMd = def?.assignmentMd,
)
}
val averageScore = averageScore(cells)
GroupStudentRow(
studentId = s.id,
fullName = s.fullName,
@@ -239,13 +257,19 @@ class AdminGroupController(
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
},
),
averageScore = averageScore,
)
}
private fun loadGroupAndLabs(
token: String,
id: String,
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
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,
@@ -253,6 +277,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
)
private fun groupViewModel(
@@ -269,7 +295,16 @@ 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,
data.attestationSemesters,
)
}
private data class GroupViewModel(
@@ -279,6 +314,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
)
private fun fillGroupModel(
@@ -291,6 +328,8 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("attestationSemesters", view.attestationSemesters)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
}
@@ -353,13 +392,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

@@ -49,6 +49,24 @@ 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 = "",
val label: String = "",
val missedHours: Int? = null,
)
/** Столбец аттестации на странице группы. */
data class GroupAttestation(
val id: String = "",
val kind: String = "",
val kindLabel: String = "",
val semester: String = "",
val results: Map<String, GroupAttestationResult> = emptyMap(),
)
/** Ряд блока защит (одна сессия на строку). */

View File

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

View File

@@ -79,14 +79,16 @@ class ApiClient(
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
fallback != null -> fallback
else -> "Ошибка запроса (${ex.statusCode.value()})"
else -> fallbackDetail(ex.statusCode.value())
}
ParsedError(detail, violations)
} catch (ignore: Exception) {
ParsedError(fallback ?: "Ошибка запроса (${ex.statusCode.value()})", emptyList())
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

View File

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

View File

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

View File

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

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,14 @@ class StudentController(
val submissionCount: Int,
)
data class StudentAttestationView(
val kindLabel: String,
val semester: String,
val valueLabel: String,
val positive: Boolean?,
val missedHours: Int?,
)
@GetMapping
fun dashboard(
session: HttpSession,
@@ -72,6 +82,26 @@ 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 = column.semester,
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
missedHours = result?.missedHours,
)
},
)
return "student/dashboard"
}

View File

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

View File

@@ -32,6 +32,59 @@ function openGradeEditor(btn) {
if (d && d.showModal) d.showModal();
}
/* ---------- Аттестация: выбор результата ---------- */
var ATTESTATION_OPTIONS = {
intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']],
pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']],
exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']]
};
function openAttestation(btn) {
var form = $id('attestation-form');
if (!form) return;
var kind = btn.getAttribute('data-attestation-kind') || 'intermediate';
var student = btn.getAttribute('data-student') || '';
var studentName = btn.getAttribute('data-student-name') || '';
var label = btn.getAttribute('data-attestation-label') || 'Аттестация';
var semester = btn.getAttribute('data-semester') || '';
var value = btn.getAttribute('data-attestation-value') || '';
$id('attestation-id').value = btn.getAttribute('data-attestation') || '';
$id('attestation-student').value = student;
$id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : '');
var select = $id('attestation-value');
select.innerHTML = '';
var empty = document.createElement('option');
empty.value = '';
empty.textContent = '— не выставлено —';
select.appendChild(empty);
(ATTESTATION_OPTIONS[kind] || []).forEach(function (item) {
var option = document.createElement('option');
option.value = item[0];
option.textContent = item[1];
select.appendChild(option);
});
select.value = value;
var hours = $id('attestation-hours');
var hoursWrap = $id('attestation-hours-wrap');
var missed = btn.getAttribute('data-missed-hours');
if (hours) hours.value = missed && missed !== 'null' ? missed : '';
if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate';
if (kind !== 'intermediate' && hours) hours.value = '';
var title = $id('attestation-title');
if (title) title.textContent = label + ' — ' + studentName;
var dialog = $id('modal-attestation');
if (dialog && dialog.showModal) dialog.showModal();
}
window.openAttestation = openAttestation;
function csRow(text, tone) {
var box = $id('cs-rows');
var row = document.createElement('div');
@@ -208,43 +261,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 +362,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 +441,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 +469,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 +642,104 @@ 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;
});
}
document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'grade-form') csSerialize();
if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize();
ev.preventDefault();
submitGradeForm(ev.target);
}, true);
window.addEventListener('DOMContentLoaded', function () {
function initGroupPage() {
focusLab();
startDefenseWatch();
attachPublishToggles();
});
}
window.__lmsInitGroupPage = initGroupPage;
window.addEventListener('DOMContentLoaded', initGroupPage);
/* ---------- Большая модалка «Выдача вариантов» ---------- */

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

View File

@@ -193,6 +193,16 @@
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;
@@ -223,6 +233,7 @@
if (!form) return;
restoreValues(form, feedback.values);
clearAllErrors(form);
if (form.hidden) form.hidden = false;
var dialog = form.closest('dialog');
@@ -245,9 +256,19 @@
}
/* Встроенная валидация: вместо браузерных пузырей — сообщение под полем. */
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 'Отметьте этот пункт';
@@ -255,8 +276,8 @@
}
if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат';
if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат';
if (v.tooShort) return 'Минимум ' + el.minLength + ' символов';
if (v.tooLong) return 'Максимум ' + el.maxLength + ' символов';
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 'Недопустимое значение';
@@ -282,10 +303,49 @@
}
}
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
/* Проверка совпадения значений (например, повтор пароля). */
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

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

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

@@ -17,18 +17,19 @@
<!-- 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='37')}">
<link rel="stylesheet" th:href="@{/css/app.css(v='38')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
<link id="hljs-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='36')}" defer></script>
<script th:src="@{/js/tablekit.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='36')}"></script>
<script th:src="@{/js/app.js(v='36')}" defer></script>
<script th:src="@{/js/app.js(v='39')}" defer></script>
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='37')}" defer></script>
<script th:src="@{/js/md-content.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>

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,30 @@
<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><th>Пропущено</th></tr>
</thead>
<tbody>
<tr th:each="a : ${attestations}">
<td th:text="${a.kindLabel}">—</td>
<td th:text="${a.semester}">—</td>
<td>
<span class="badge"
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
th:text="${a.valueLabel}">—</span>
</td>
<td class="text-muted" th:text="${a.missedHours != null ? a.missedHours + ' ч.' : '—'}">—</td>
</tr>
</tbody>
</table>
</div>
<div th:if="${#lists.isEmpty(attestations)}" class="card card-body text-muted mb-6">
Аттестаций пока нет. Преподаватель добавит результаты после их выставления.
</div>
<h2 class="section-title mt-8">Мои лабораторные</h2>
<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

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