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.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice 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. */ /** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
@ExceptionHandler(DataIntegrityViolationException::class) @ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrity(): ResponseEntity<ApiError> = fun handleDataIntegrity(): ResponseEntity<ApiError> =

View File

@@ -20,7 +20,7 @@ spring:
- id: progress - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: 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: management:
endpoints: 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 = "", 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( data class SubmissionDto(
val id: String, val id: String,
val seq: Int, val seq: Int,

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.DefenseSessionRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
@@ -21,6 +22,7 @@ class StudentPurgeService(
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val publicationsSt: GradePublicationStudentRepository, private val publicationsSt: GradePublicationStudentRepository,
private val attestationResults: AttestationResultRepository,
private val storage: SubmissionStorage, private val storage: SubmissionStorage,
) { ) {
/** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */ /** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */
@@ -33,6 +35,7 @@ class StudentPurgeService(
assignments.findByStudentId(studentId).also { assignments.deleteAll(it) } assignments.findByStudentId(studentId).also { assignments.deleteAll(it) }
levels.findByStudentId(studentId).also { levels.deleteAll(it) } levels.findByStudentId(studentId).also { levels.deleteAll(it) }
publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) } publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) }
attestationResults.findByStudentId(studentId).also { attestationResults.deleteAll(it) }
} }
fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId) 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 = "", 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( data class SubmissionDownloadForm(
val group: String? = null, val group: String? = null,
val no: 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.importGroup
import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.labSubmissions 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.listGrades
import ru.ysu.lms.web.api.listGroups import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs 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.setPublication
import ru.ysu.lms.web.api.suggestComments import ru.ysu.lms.web.api.suggestComments
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -137,7 +140,11 @@ class AdminGroupController(
model: Model, model: Model,
): String { ): String {
val t = token(session) 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 grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -166,19 +173,29 @@ class AdminGroupController(
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
val rows = val rows =
buildRows( GroupAttestationViews.attach(
group.students, buildRows(
labs, group.students,
gradeBy, labs,
generalCommentBy, gradeBy,
commentsBy, generalCommentBy,
publishedByLab, commentsBy,
assignmentsBy, publishedByLab,
submissionsBy, assignmentsBy,
defensesBy, 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" return "admin/group"
} }
@@ -222,6 +239,7 @@ class AdminGroupController(
defenseAssignmentMd = def?.assignmentMd, defenseAssignmentMd = def?.assignmentMd,
) )
} }
val averageScore = averageScore(cells)
GroupStudentRow( GroupStudentRow(
studentId = s.id, studentId = s.id,
fullName = s.fullName, fullName = s.fullName,
@@ -239,13 +257,19 @@ class AdminGroupController(
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
}, },
), ),
averageScore = averageScore,
) )
} }
private fun loadGroupAndLabs( private fun averageScore(cells: List<GroupLabCell>): String? {
token: String, val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
id: String, if (scores.isEmpty()) return null
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" } return scores
.fold(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
}
private data class GroupRenderData( private data class GroupRenderData(
val group: GroupDetailDto, val group: GroupDetailDto,
@@ -253,6 +277,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>, val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun groupViewModel( private fun groupViewModel(
@@ -269,7 +295,16 @@ class AdminGroupController(
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0) lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
} }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) 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( private data class GroupViewModel(
@@ -279,6 +314,8 @@ class AdminGroupController(
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>, val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun fillGroupModel( private fun fillGroupModel(
@@ -291,6 +328,8 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("attestationSemesters", view.attestationSemesters)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }
@@ -353,13 +392,16 @@ class AdminGroupController(
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val score = form.score.trim()
if (score.isEmpty()) throw ApiException(400, "Укажите оценку")
if (score.toBigDecimalOrNull() == null) throw ApiException(400, "Оценка должна быть числом")
val comments = parseComments(form.commentsJson ?: "[]") val comments = parseComments(form.commentsJson ?: "[]")
api.setGrade( api.setGrade(
token(session), token(session),
GradeSetPayload( GradeSetPayload(
studentId = form.studentId, studentId = form.studentId,
labId = form.labId, labId = form.labId,
score = form.score, score = score,
comments = comments, comments = comments,
generalComment = form.generalComment, generalComment = form.generalComment,
), ),

View File

@@ -49,6 +49,24 @@ data class GroupStudentRow(
val cells: List<GroupLabCell>, val cells: List<GroupLabCell>,
val variantsSummary: String, val variantsSummary: String,
val commentsJson: 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 rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
violations.isNotEmpty() -> violations.joinToString("; ") { it.message } violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
fallback != null -> fallback fallback != null -> fallback
else -> "Ошибка запроса (${ex.statusCode.value()})" else -> fallbackDetail(ex.statusCode.value())
} }
ParsedError(detail, violations) ParsedError(detail, violations)
} catch (ignore: Exception) { } 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? { private fun detailFromBody(body: String): String? {
val match = DETAIL_PATTERN.find(body) val match = DETAIL_PATTERN.find(body)
return match 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 = "", 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) @JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto( data class CommentDto(
val text: String = "", 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("number")), Rule(Regex("(?iu)номер варианта"), listOf("number")),
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")), Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
Rule(Regex("(?iu)оценк"), listOf("score")), 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("pointsMax", "score")),
Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")), Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")),
Rule( Rule(

View File

@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.AttestationPresentation
import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
@@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.finishDefense
import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.meAssignments 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.myDefense
import ru.ysu.lms.web.api.myDefenseStatus import ru.ysu.lms.web.api.myDefenseStatus
import ru.ysu.lms.web.api.myGrades import ru.ysu.lms.web.api.myGrades
@@ -59,6 +61,14 @@ class StudentController(
val submissionCount: Int, val submissionCount: Int,
) )
data class StudentAttestationView(
val kindLabel: String,
val semester: String,
val valueLabel: String,
val positive: Boolean?,
val missedHours: Int?,
)
@GetMapping @GetMapping
fun dashboard( fun dashboard(
session: HttpSession, session: HttpSession,
@@ -72,6 +82,26 @@ class StudentController(
model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat()) model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat())
model.addAttribute("best", scored.maxOrNull()) model.addAttribute("best", scored.maxOrNull())
model.addAttribute("worst", scored.minOrNull()) 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" 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 tbody tr:last-child td { border-bottom: none; }
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); }
.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; } .matrix td.student-cell { min-width: 12rem; }
.student-name { font-weight: 600; font-size: 0.84375rem; } .student-name { font-weight: 600; font-size: 0.84375rem; }
.student-cell .font-mono { margin-top: 0.125rem; } .student-cell .font-mono { margin-top: 0.125rem; }

View File

@@ -32,6 +32,59 @@ function openGradeEditor(btn) {
if (d && d.showModal) d.showModal(); if (d && d.showModal) d.showModal();
} }
/* ---------- Аттестация: выбор результата ---------- */
var ATTESTATION_OPTIONS = {
intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']],
pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']],
exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']]
};
function openAttestation(btn) {
var form = $id('attestation-form');
if (!form) return;
var kind = btn.getAttribute('data-attestation-kind') || 'intermediate';
var student = btn.getAttribute('data-student') || '';
var studentName = btn.getAttribute('data-student-name') || '';
var label = btn.getAttribute('data-attestation-label') || 'Аттестация';
var semester = btn.getAttribute('data-semester') || '';
var value = btn.getAttribute('data-attestation-value') || '';
$id('attestation-id').value = btn.getAttribute('data-attestation') || '';
$id('attestation-student').value = student;
$id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : '');
var select = $id('attestation-value');
select.innerHTML = '';
var empty = document.createElement('option');
empty.value = '';
empty.textContent = '— не выставлено —';
select.appendChild(empty);
(ATTESTATION_OPTIONS[kind] || []).forEach(function (item) {
var option = document.createElement('option');
option.value = item[0];
option.textContent = item[1];
select.appendChild(option);
});
select.value = value;
var hours = $id('attestation-hours');
var hoursWrap = $id('attestation-hours-wrap');
var missed = btn.getAttribute('data-missed-hours');
if (hours) hours.value = missed && missed !== 'null' ? missed : '';
if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate';
if (kind !== 'intermediate' && hours) hours.value = '';
var title = $id('attestation-title');
if (title) title.textContent = label + ' — ' + studentName;
var dialog = $id('modal-attestation');
if (dialog && dialog.showModal) dialog.showModal();
}
window.openAttestation = openAttestation;
function csRow(text, tone) { function csRow(text, tone) {
var box = $id('cs-rows'); var box = $id('cs-rows');
var row = document.createElement('div'); var row = document.createElement('div');
@@ -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) { function removeCommentTemplate(text, done) {
var form = $id('grade-form'); var form = $id('grade-form');
@@ -345,6 +362,8 @@ function updateEyeCounts() {
var DEFENSE_STATE = {}; var DEFENSE_STATE = {};
var DEFENSE_POLL_MS = 10000; var DEFENSE_POLL_MS = 10000;
var DEFENSE_TICK_MS = 1000; var DEFENSE_TICK_MS = 1000;
var DEFENSE_POLL_TIMER = null;
var DEFENSE_TICK_TIMER = null;
function defenseBtn(sessionId) { function defenseBtn(sessionId) {
if (!sessionId) return null; if (!sessionId) return null;
@@ -422,6 +441,14 @@ function syncDefenseStatuses(sessions, initial) {
} }
function startDefenseWatch() { 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'); var meta = $id('defense-meta');
if (!meta) return; if (!meta) return;
var labs = meta.getAttribute('data-labs') || ''; var labs = meta.getAttribute('data-labs') || '';
@@ -442,8 +469,8 @@ function startDefenseWatch() {
} }
poll(); poll();
setInterval(poll, DEFENSE_POLL_MS); DEFENSE_POLL_TIMER = setInterval(poll, DEFENSE_POLL_MS);
setInterval(function () { DEFENSE_TICK_TIMER = setInterval(function () {
Object.keys(DEFENSE_STATE).forEach(function (id) { Object.keys(DEFENSE_STATE).forEach(function (id) {
var s = DEFENSE_STATE[id]; var s = DEFENSE_STATE[id];
if (s.status === 'started') applyDefenseToCell(s); 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) { 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); }, true);
window.addEventListener('DOMContentLoaded', function () { function initGroupPage() {
focusLab(); focusLab();
startDefenseWatch(); startDefenseWatch();
attachPublishToggles(); attachPublishToggles();
}); }
window.__lmsInitGroupPage = initGroupPage;
window.addEventListener('DOMContentLoaded', initGroupPage);
/* ---------- Большая модалка «Выдача вариантов» ---------- */ /* ---------- Большая модалка «Выдача вариантов» ---------- */

View File

@@ -75,6 +75,7 @@
function initSections() { function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState); document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
} }
window.__lmsInitSections = initSections;
window.toggleSection = function (btn) { window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc'); var acc = btn.closest('.sub-acc');
if (!acc) return; 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 () { document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll(); window.__hljsAll();
@@ -236,6 +270,7 @@
var kind = action.getAttribute('data-action'); var kind = action.getAttribute('data-action');
var fn = { var fn = {
grade: window.openGradeEditor, grade: window.openGradeEditor,
attestation: window.openAttestation,
variant: window.openVariant, variant: window.openVariant,
defense: window.openDefense, defense: window.openDefense,
assign: window.openLabAssign, assign: window.openLabAssign,

View File

@@ -193,6 +193,16 @@
form.insertBefore(box, form.firstChild); 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) { function focusFirstInvalid(form) {
var el = form.querySelector('.' + INVALID); var el = form.querySelector('.' + INVALID);
if (!el || typeof el.focus !== 'function') return; if (!el || typeof el.focus !== 'function') return;
@@ -223,6 +233,7 @@
if (!form) return; if (!form) return;
restoreValues(form, feedback.values); restoreValues(form, feedback.values);
clearAllErrors(form);
if (form.hidden) form.hidden = false; if (form.hidden) form.hidden = false;
var dialog = form.closest('dialog'); 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) { function validationMessage(el) {
var v = el.validity || {}; var v = el.validity || {};
if (v.valueMissing) { if (v.valueMissing) {
var requiredMessage = el.getAttribute('data-error-required');
if (requiredMessage) return requiredMessage;
if (el.type === 'file') return 'Выберите файл'; if (el.type === 'file') return 'Выберите файл';
if (el.tagName === 'SELECT') return 'Выберите значение'; if (el.tagName === 'SELECT') return 'Выберите значение';
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт'; if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
@@ -255,8 +276,8 @@
} }
if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат'; if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат';
if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат'; if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат';
if (v.tooShort) return 'Минимум ' + el.minLength + ' символов'; if (v.tooShort) return 'Минимум ' + el.minLength + ' ' + symbolWord(el.minLength);
if (v.tooLong) return 'Максимум ' + el.maxLength + ' символов'; if (v.tooLong) return 'Максимум ' + el.maxLength + ' ' + symbolWord(el.maxLength);
if (v.rangeUnderflow) return 'Значение не меньше ' + el.min; if (v.rangeUnderflow) return 'Значение не меньше ' + el.min;
if (v.rangeOverflow) return 'Значение не больше ' + el.max; if (v.rangeOverflow) return 'Значение не больше ' + el.max;
if (v.stepMismatch) return 'Недопустимое значение'; 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.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-match]').forEach(checkMatch);
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку // после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0); setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
}); });

View File

@@ -70,6 +70,7 @@
function ready() { function ready() {
document.querySelectorAll('.md-body pre').forEach(enhance); document.querySelectorAll('.md-body pre').forEach(enhance);
} }
window.__lmsInitMdContent = ready;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready); 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() { function ready() {
document.querySelectorAll('table').forEach(wrap); document.querySelectorAll('table').forEach(wrap);
} }
window.__lmsInitTableScroll = ready;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready); document.addEventListener('DOMContentLoaded', ready);

View File

@@ -192,8 +192,15 @@
}); });
} }
document.addEventListener('DOMContentLoaded', function () { function init() {
document.querySelectorAll('table.tablekit').forEach(bind); document.querySelectorAll('table.tablekit').forEach(bind);
document.querySelectorAll('input.table-search').forEach(bindSearch); 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>
<div class="actions"> <div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</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> <button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div> </div>
</div> </div>
@@ -30,10 +31,10 @@
</select> </select>
</div> </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})}"> <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 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"> <div class="scroll-x">
<table class="matrix tablekit" id="group-table"> <table class="matrix tablekit" id="group-table">
<thead> <thead>
@@ -70,6 +71,23 @@
</div> </div>
</div> </div>
</th> </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 data-nosort>Пароль</th>
<th class="text-right" data-nosort>Действия</th> <th class="text-right" data-nosort>Действия</th>
</tr> </tr>
@@ -120,6 +138,19 @@
</div> </div>
</div> </div>
</td> </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"> <td class="text-center">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span> <span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span> <span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
@@ -139,7 +170,7 @@
</div> </div>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@@ -159,7 +190,8 @@
<input type="hidden" name="commentsJson" id="grade-comments" value="[]"> <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">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label> <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> <div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
<textarea class="input" id="grade-general" name="generalComment" rows="4" <textarea class="input" id="grade-general" name="generalComment" rows="4"
placeholder="Итоговый комментарий по работе в целом…"></textarea></div> placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
@@ -172,6 +204,64 @@
</div> </div>
</dialog> </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"> <dialog id="modal-variant" class="dlg dlg-lg">
@@ -277,7 +367,8 @@
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p> <p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p>
<button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button> <button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
</form> </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}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label> <div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label>
@@ -345,7 +436,7 @@
<div class="dlg-body" id="assign-modal-body"></div> <div class="dlg-body" id="assign-modal-body"></div>
</dialog> </dialog>
<script th:src="@{/js/admin-group.js(v='37')}" defer></script> <script th:src="@{/js/admin-group.js(v='40')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -14,8 +14,8 @@
</p> </p>
<form class="card form-card form-stack" th:action="@{/change-password}" method="post"> <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="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="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" required></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> <button class="btn btn-primary" type="submit">Сменить пароль</button>
<a th:if="${currentUser != null and !currentUser.mustChangePassword}" <a th:if="${currentUser != null and !currentUser.mustChangePassword}"
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a> class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>

View File

@@ -17,18 +17,19 @@
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию --> <!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
<link rel="stylesheet" th:href="@{/css/app.css(v='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-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled> <link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script> <script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='36')}" defer></script> <script th:src="@{/js/table-scroll.js(v='38')}" defer></script>
<script th:src="@{/js/tablekit.js(v='36')}" defer></script> <script th:src="@{/js/tablekit.js(v='38')}" defer></script>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script> <script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/theme-init.js(v='36')}"></script> <script th:src="@{/js/theme-init.js(v='36')}"></script>
<script th:src="@{/js/app.js(v='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/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='37')}" defer></script> <script th:src="@{/js/form-feedback.js(v='39')}" defer></script>
<script th:src="@{/js/md-content.js(v='36')}" 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> <script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
</head> </head>
<body> <body>

View File

@@ -7,9 +7,9 @@
<h1 class="page-title">Создание администратора</h1> <h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p> <p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<form class="card form-card form-stack" th:action="@{/setup}" method="post"> <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="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" required><p class="hint">Минимум 8 символов.</p></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" required></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> <button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>

View File

@@ -24,6 +24,30 @@
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a> <a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div> </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> <h2 class="section-title mt-8">Мои лабораторные</h2>
<div class="row row-cols-1 row-cols-lg-2 g-4"> <div class="row row-cols-1 row-cols-lg-2 g-4">
<div th:each="c : ${cards}" class="col"><div class="card h-100"> <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"])
}
}