Compare commits
19 Commits
d287dd3119
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 920d96a0f5 | |||
| 2beffd84f1 | |||
| d8e28e88fc | |||
| 44edb5ebeb | |||
| e2e3ed1eaf | |||
| 7fb3e13548 | |||
| 9ddf2c6ce9 | |||
| a336612ad5 | |||
| c8581c620a | |||
| 54e5721267 | |||
| 26573f2210 | |||
| bfdae41fb8 | |||
| 9e16566160 | |||
| dc13ba3186 | |||
| f5ba21b5f5 | |||
| fb9511280a | |||
| f7a1830548 | |||
| e22443b08e | |||
| 2e43f79256 |
@@ -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> =
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
class AttestationController(
|
||||||
|
private val service: AttestationService,
|
||||||
|
) {
|
||||||
|
@GetMapping("/attestations")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun list(
|
||||||
|
@RequestParam groupId: UUID,
|
||||||
|
): List<AttestationResponse> = service.list(groupId)
|
||||||
|
|
||||||
|
@PostMapping("/attestations")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun create(
|
||||||
|
@RequestBody body: AttestationCreateRequest,
|
||||||
|
): AttestationResponse = service.create(body)
|
||||||
|
|
||||||
|
@DeleteMapping("/attestations/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun delete(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): Map<String, Boolean> {
|
||||||
|
service.delete(id)
|
||||||
|
return mapOf("ok" to true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/attestations/{id}/publication")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun setPublished(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: AttestationPublicationRequest,
|
||||||
|
): AttestationResponse = service.setPublished(id, body.published)
|
||||||
|
|
||||||
|
@PostMapping("/attestations/{id}/results")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun setResult(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: AttestationResultRequest,
|
||||||
|
): AttestationResponse = service.setResult(id, body)
|
||||||
|
|
||||||
|
/** Результаты аттестаций текущего студента по его группе. */
|
||||||
|
@GetMapping("/me/attestations")
|
||||||
|
fun myAttestations(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@RequestParam(required = false) groupId: UUID?,
|
||||||
|
): List<AttestationResponse> {
|
||||||
|
if (groupId == null) return emptyList()
|
||||||
|
return service.listForStudent(groupId, UUID.fromString(jwt.subject))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import ru.ysu.lms.progress.domain.Attestation
|
||||||
|
import ru.ysu.lms.progress.domain.AttestationResult
|
||||||
|
import ru.ysu.lms.progress.domain.AttestationResultKey
|
||||||
|
import ru.ysu.lms.progress.repo.AttestationRepository
|
||||||
|
import ru.ysu.lms.progress.repo.AttestationResultRepository
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AttestationService(
|
||||||
|
private val attestations: AttestationRepository,
|
||||||
|
private val results: AttestationResultRepository,
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun list(groupId: UUID): List<AttestationResponse> {
|
||||||
|
val columns = attestations.findByGroupIdOrderByCreatedAtAscIdAsc(groupId)
|
||||||
|
if (columns.isEmpty()) return emptyList()
|
||||||
|
val byAttestation = results.findByAttestationIdIn(columns.map { it.id }).groupBy { it.attestationId }
|
||||||
|
return columns.map { toResponse(it, byAttestation[it.id].orEmpty()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listForStudent(
|
||||||
|
groupId: UUID,
|
||||||
|
studentId: UUID,
|
||||||
|
): List<AttestationResponse> =
|
||||||
|
list(groupId)
|
||||||
|
.filter { it.published }
|
||||||
|
.map { column ->
|
||||||
|
column.copy(results = column.results.filter { it.studentId == studentId.toString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(request: AttestationCreateRequest): AttestationResponse {
|
||||||
|
val kind = normalizeKind(request.kind)
|
||||||
|
if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8")
|
||||||
|
val column =
|
||||||
|
attestations.save(
|
||||||
|
Attestation(
|
||||||
|
groupId = UUID.fromString(request.groupId),
|
||||||
|
kind = kind,
|
||||||
|
semester = request.semester,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return toResponse(column, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun setPublished(
|
||||||
|
attestationId: UUID,
|
||||||
|
published: Boolean,
|
||||||
|
): AttestationResponse {
|
||||||
|
val column =
|
||||||
|
attestations
|
||||||
|
.findById(attestationId)
|
||||||
|
.orElseThrow { NotFoundException("Аттестация не найдена") }
|
||||||
|
column.published = published
|
||||||
|
attestations.save(column)
|
||||||
|
return toResponse(column, results.findByAttestationId(attestationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun delete(attestationId: UUID) {
|
||||||
|
val column =
|
||||||
|
attestations
|
||||||
|
.findById(attestationId)
|
||||||
|
.orElseThrow { NotFoundException("Аттестация не найдена") }
|
||||||
|
attestations.delete(column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun setResult(
|
||||||
|
attestationId: UUID,
|
||||||
|
request: AttestationResultRequest,
|
||||||
|
): AttestationResponse {
|
||||||
|
val column =
|
||||||
|
attestations
|
||||||
|
.findById(attestationId)
|
||||||
|
.orElseThrow { NotFoundException("Аттестация не найдена") }
|
||||||
|
val studentId = UUID.fromString(request.studentId)
|
||||||
|
val key = AttestationResultKey(attestationId, studentId)
|
||||||
|
val value = request.value.trim().lowercase()
|
||||||
|
val existing = results.findById(key).orElse(null)
|
||||||
|
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
existing?.let { results.delete(it) }
|
||||||
|
return toResponse(column, results.findByAttestationId(attestationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
val allowed = VALUES[column.kind] ?: emptySet()
|
||||||
|
if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации")
|
||||||
|
|
||||||
|
val entity =
|
||||||
|
(existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply {
|
||||||
|
this.value = value
|
||||||
|
this.updatedAt = Instant.now()
|
||||||
|
}
|
||||||
|
results.save(entity)
|
||||||
|
return toResponse(column, results.findByAttestationId(attestationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeKind(raw: String): String {
|
||||||
|
val kind = raw.trim().lowercase()
|
||||||
|
if (kind !in KINDS) throw BadRequestException("Неизвестный тип аттестации")
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toResponse(
|
||||||
|
column: Attestation,
|
||||||
|
rows: List<AttestationResult>,
|
||||||
|
): AttestationResponse =
|
||||||
|
AttestationResponse(
|
||||||
|
id = column.id.toString(),
|
||||||
|
groupId = column.groupId.toString(),
|
||||||
|
kind = column.kind,
|
||||||
|
semester = column.semester,
|
||||||
|
published = column.published,
|
||||||
|
results =
|
||||||
|
rows
|
||||||
|
.sortedBy { it.studentId }
|
||||||
|
.map { AttestationResultResponse(it.studentId.toString(), it.value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND_INTERMEDIATE = "intermediate"
|
||||||
|
const val KIND_PASS = "pass"
|
||||||
|
const val KIND_EXAM = "exam"
|
||||||
|
|
||||||
|
private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM)
|
||||||
|
private val VALUES =
|
||||||
|
mapOf(
|
||||||
|
KIND_INTERMEDIATE to setOf("excellent", "satisfactory", "unsatisfactory", "not_attested"),
|
||||||
|
KIND_PASS to setOf("passed", "failed", "absent"),
|
||||||
|
KIND_EXAM to setOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,38 @@ data class GradeResponse(
|
|||||||
val generalComment: String = "",
|
val generalComment: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class AttestationCreateRequest(
|
||||||
|
val groupId: String,
|
||||||
|
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
|
||||||
|
val kind: String,
|
||||||
|
/** Номер семестра: 1–8. */
|
||||||
|
val semester: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationResultRequest(
|
||||||
|
val studentId: String,
|
||||||
|
/** Пустое значение очищает результат. */
|
||||||
|
val value: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationResultResponse(
|
||||||
|
val studentId: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationPublicationRequest(
|
||||||
|
val published: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationResponse(
|
||||||
|
val id: String,
|
||||||
|
val groupId: String,
|
||||||
|
val kind: String,
|
||||||
|
val semester: Int,
|
||||||
|
val published: Boolean = false,
|
||||||
|
val results: List<AttestationResultResponse> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class SubmissionDto(
|
data class SubmissionDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val seq: Int,
|
val seq: Int,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.ysu.lms.progress.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.IdClass
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import ru.ysu.lms.common.Ids
|
||||||
|
import java.io.Serializable
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "attestations")
|
||||||
|
class Attestation(
|
||||||
|
@Id var id: UUID = Ids.v7(),
|
||||||
|
@Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
|
||||||
|
@Column(nullable = false, length = 16) var kind: String = "intermediate",
|
||||||
|
@Column(nullable = false) var semester: Int = 1,
|
||||||
|
@Column(nullable = false) var published: Boolean = false,
|
||||||
|
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "attestation_results")
|
||||||
|
@IdClass(AttestationResultKey::class)
|
||||||
|
class AttestationResult(
|
||||||
|
@Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
|
||||||
|
@Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
|
||||||
|
@Column(nullable = false, length = 32) var value: String = "",
|
||||||
|
@Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationResultKey(
|
||||||
|
val attestationId: UUID = UUID(0, 0),
|
||||||
|
val studentId: UUID = UUID(0, 0),
|
||||||
|
) : Serializable {
|
||||||
|
companion object {
|
||||||
|
private const val serialVersionUID: Long = 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Семестр аттестации — номер 1–8; пропуски пока не учитываем.
|
||||||
|
ALTER TABLE attestations ADD COLUMN semester_no integer;
|
||||||
|
|
||||||
|
UPDATE attestations
|
||||||
|
SET semester_no = NULLIF(regexp_replace(semester, '[^0-9]', '', 'g'), '')::integer
|
||||||
|
WHERE regexp_replace(semester, '[^0-9]', '', 'g') ~ '^[1-8]$';
|
||||||
|
|
||||||
|
UPDATE attestations SET semester_no = 1 WHERE semester_no IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE attestations ALTER COLUMN semester_no SET NOT NULL;
|
||||||
|
ALTER TABLE attestations DROP COLUMN semester;
|
||||||
|
ALTER TABLE attestations RENAME COLUMN semester_no TO semester;
|
||||||
|
ALTER TABLE attestations ADD CONSTRAINT attestations_semester_check CHECK (semester BETWEEN 1 AND 8);
|
||||||
|
|
||||||
|
ALTER TABLE attestations ADD COLUMN published boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE attestation_results DROP COLUMN missed_hours;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package ru.ysu.lms.web
|
||||||
|
|
||||||
|
/** Подписи типов и оценок аттестации для интерфейса. */
|
||||||
|
object AttestationPresentation {
|
||||||
|
private val KIND_LABELS =
|
||||||
|
mapOf(
|
||||||
|
"intermediate" to "Промежуточная аттестация",
|
||||||
|
"pass" to "Зачёт",
|
||||||
|
"exam" to "Экзамен",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val VALUE_LABELS =
|
||||||
|
mapOf(
|
||||||
|
"intermediate" to
|
||||||
|
mapOf(
|
||||||
|
"excellent" to "отлично",
|
||||||
|
"satisfactory" to "аттестован",
|
||||||
|
"unsatisfactory" to "неудовлетворительно",
|
||||||
|
"not_attested" to "не аттестован",
|
||||||
|
),
|
||||||
|
"pass" to
|
||||||
|
mapOf(
|
||||||
|
"passed" to "зачтено",
|
||||||
|
"failed" to "не зачтено",
|
||||||
|
"absent" to "неявка",
|
||||||
|
),
|
||||||
|
"exam" to
|
||||||
|
mapOf(
|
||||||
|
"excellent" to "отлично",
|
||||||
|
"good" to "хорошо",
|
||||||
|
"satisfactory" to "удовлетворительно",
|
||||||
|
"unsatisfactory" to "неудовлетворительно",
|
||||||
|
"absent" to "неявка",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val POSITIVE_VALUES =
|
||||||
|
mapOf(
|
||||||
|
"intermediate" to setOf("excellent", "satisfactory"),
|
||||||
|
"pass" to setOf("passed"),
|
||||||
|
"exam" to setOf("excellent", "good", "satisfactory"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind
|
||||||
|
|
||||||
|
/** Короткая подпись для шапки узкого столбца. */
|
||||||
|
fun shortKindLabel(kind: String): String =
|
||||||
|
when (kind) {
|
||||||
|
"intermediate" -> "Пр. атт."
|
||||||
|
"pass" -> "Зачёт"
|
||||||
|
"exam" -> "Экзамен"
|
||||||
|
else -> kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Понимает и «2», и старые значения вроде «2 семестр»; вне 1–8 — 1. */
|
||||||
|
fun semesterNumber(raw: String): Int {
|
||||||
|
val digits = raw.filter { it.isDigit() }
|
||||||
|
val value = digits.toIntOrNull() ?: return 1
|
||||||
|
return if (value in 1..8) value else 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun valueLabel(
|
||||||
|
kind: String,
|
||||||
|
value: String,
|
||||||
|
): String = VALUE_LABELS[kind]?.get(value) ?: value
|
||||||
|
|
||||||
|
fun isPositive(
|
||||||
|
kind: String,
|
||||||
|
value: String,
|
||||||
|
): Boolean? = if (value.isBlank()) null else POSITIVE_VALUES[kind]?.contains(value) == true
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AttestationCreatePayload
|
||||||
|
import ru.ysu.lms.web.api.AttestationPublicationPayload
|
||||||
|
import ru.ysu.lms.web.api.AttestationResultPayload
|
||||||
|
import ru.ysu.lms.web.api.createAttestation
|
||||||
|
import ru.ysu.lms.web.api.deleteAttestation
|
||||||
|
import ru.ysu.lms.web.api.setAttestationPublication
|
||||||
|
import ru.ysu.lms.web.api.setAttestationResult
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminAttestationController(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
@PostMapping("/groups/{id}/attestations")
|
||||||
|
fun createAttestation(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@ModelAttribute form: CreateAttestationForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val semester = form.semester.trim().toIntOrNull()
|
||||||
|
if (semester == null || semester !in 1..8) throw ApiException(400, "Укажите семестр от 1 до 8")
|
||||||
|
api.createAttestation(
|
||||||
|
token(session),
|
||||||
|
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Столбец аттестации добавлен")
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{id}/attestations/{attestationId}/delete")
|
||||||
|
fun deleteAttestation(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@PathVariable attestationId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.deleteAttestation(token(session), attestationId)
|
||||||
|
redirect.addFlashAttribute("notice", "Столбец аттестации удалён")
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{id}/attestation-results")
|
||||||
|
fun setAttestationResult(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@ModelAttribute form: SetAttestationResultForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.setAttestationResult(
|
||||||
|
token(session),
|
||||||
|
form.attestationId,
|
||||||
|
AttestationResultPayload(
|
||||||
|
studentId = form.studentId,
|
||||||
|
value = form.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{id}/attestations/{attestationId}/publication")
|
||||||
|
@ResponseBody
|
||||||
|
fun setAttestationPublication(
|
||||||
|
@PathVariable attestationId: String,
|
||||||
|
@RequestParam published: Boolean,
|
||||||
|
session: HttpSession,
|
||||||
|
): Map<String, Any> =
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
api.setAttestationPublication(
|
||||||
|
token(session),
|
||||||
|
attestationId,
|
||||||
|
AttestationPublicationPayload(published),
|
||||||
|
)
|
||||||
|
mapOf("ok" to true, "published" to result.published)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
mapOf("ok" to false, "error" to ex.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun token(session: HttpSession): String =
|
||||||
|
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||||
|
}
|
||||||
@@ -85,6 +85,17 @@ 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 = "",
|
||||||
|
)
|
||||||
|
|
||||||
data class SubmissionDownloadForm(
|
data class SubmissionDownloadForm(
|
||||||
val group: String? = null,
|
val group: String? = null,
|
||||||
val no: String? = null,
|
val no: String? = null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.ysu.lms.web.admin
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpSession
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -38,6 +39,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 +48,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
|
||||||
|
|
||||||
@@ -56,6 +60,8 @@ class AdminGroupController(
|
|||||||
markdown: MarkdownRenderer,
|
markdown: MarkdownRenderer,
|
||||||
mapper: ObjectMapper,
|
mapper: ObjectMapper,
|
||||||
) : AdminSupport(api, markdown, mapper) {
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun dashboard(
|
fun dashboard(
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
@@ -137,7 +143,10 @@ 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 = loadAttestations(t, id)
|
||||||
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 +175,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),
|
||||||
|
),
|
||||||
|
)
|
||||||
return "admin/group"
|
return "admin/group"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +241,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 +259,30 @@ 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 loadAttestations(
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
groupId: String,
|
||||||
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
|
): List<GroupAttestation> =
|
||||||
|
GroupAttestationViews.columns(
|
||||||
|
runCatching { api.listAttestations(token, groupId) }
|
||||||
|
.onFailure { log.warn("Не удалось загрузить аттестации группы {}", groupId, it) }
|
||||||
|
.getOrDefault(emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun averageScore(cells: List<GroupLabCell>): String? {
|
||||||
|
val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
|
||||||
|
if (scores.isEmpty()) return null
|
||||||
|
return scores
|
||||||
|
.fold(BigDecimal.ZERO, BigDecimal::add)
|
||||||
|
.divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros()
|
||||||
|
.toPlainString()
|
||||||
|
}
|
||||||
|
|
||||||
private data class GroupRenderData(
|
private data class GroupRenderData(
|
||||||
val group: GroupDetailDto,
|
val group: GroupDetailDto,
|
||||||
@@ -253,6 +290,7 @@ 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>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun groupViewModel(
|
private fun groupViewModel(
|
||||||
@@ -269,7 +307,15 @@ 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class GroupViewModel(
|
private data class GroupViewModel(
|
||||||
@@ -279,6 +325,7 @@ 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>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fillGroupModel(
|
private fun fillGroupModel(
|
||||||
@@ -291,6 +338,7 @@ 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("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
|
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,13 +401,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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,6 +49,31 @@ 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 = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Вариант оценки в выпадающем списке аттестации. */
|
||||||
|
data class AttestationOption(
|
||||||
|
val value: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Столбец аттестации на странице группы. */
|
||||||
|
data class GroupAttestation(
|
||||||
|
val id: String = "",
|
||||||
|
val kind: String = "",
|
||||||
|
val kindLabel: String = "",
|
||||||
|
val shortLabel: String = "",
|
||||||
|
val semester: Int = 1,
|
||||||
|
val published: Boolean = false,
|
||||||
|
val options: List<AttestationOption> = emptyList(),
|
||||||
|
val results: Map<String, GroupAttestationResult> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Ряд блока защит (одна сессия на строку). */
|
/** Ряд блока защит (одна сессия на строку). */
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import ru.ysu.lms.web.AttestationPresentation
|
||||||
|
import ru.ysu.lms.web.api.AttestationDto
|
||||||
|
|
||||||
|
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */
|
||||||
|
object GroupAttestationViews {
|
||||||
|
private val VALUES_BY_KIND =
|
||||||
|
mapOf(
|
||||||
|
"intermediate" to listOf("excellent", "satisfactory", "unsatisfactory", "not_attested"),
|
||||||
|
"pass" to listOf("passed", "failed", "absent"),
|
||||||
|
"exam" to listOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun columns(attestations: List<AttestationDto>): List<GroupAttestation> =
|
||||||
|
attestations.map { column ->
|
||||||
|
GroupAttestation(
|
||||||
|
id = column.id,
|
||||||
|
kind = column.kind,
|
||||||
|
kindLabel = AttestationPresentation.kindLabel(column.kind),
|
||||||
|
shortLabel = AttestationPresentation.shortKindLabel(column.kind),
|
||||||
|
semester = AttestationPresentation.semesterNumber(column.semester),
|
||||||
|
published = column.published,
|
||||||
|
options =
|
||||||
|
VALUES_BY_KIND[column.kind].orEmpty().map { value ->
|
||||||
|
AttestationOption(value, AttestationPresentation.valueLabel(column.kind, value))
|
||||||
|
},
|
||||||
|
results =
|
||||||
|
column.results.associate { row ->
|
||||||
|
row.studentId to GroupAttestationResult(value = row.value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attach(
|
||||||
|
rows: List<GroupStudentRow>,
|
||||||
|
columns: List<GroupAttestation>,
|
||||||
|
): List<GroupStudentRow> =
|
||||||
|
rows.map { row ->
|
||||||
|
val byColumn = LinkedHashMap<String, GroupAttestationResult>()
|
||||||
|
for (column in columns) {
|
||||||
|
val result = column.results[row.studentId] ?: continue
|
||||||
|
byColumn[column.id] = result
|
||||||
|
}
|
||||||
|
row.copy(attestations = byColumn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import org.springframework.core.ParameterizedTypeReference
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
|
||||||
|
fun ApiClient.listAttestations(
|
||||||
|
token: String,
|
||||||
|
groupId: String,
|
||||||
|
): List<AttestationDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
|
||||||
|
return call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/attestations?groupId={gid}", groupId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.body(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.createAttestation(
|
||||||
|
token: String,
|
||||||
|
payload: AttestationCreatePayload,
|
||||||
|
): AttestationDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/attestations")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(payload)
|
||||||
|
.retrieve()
|
||||||
|
.body(AttestationDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.deleteAttestation(
|
||||||
|
token: String,
|
||||||
|
attestationId: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.delete()
|
||||||
|
.uri("/api/v1/attestations/{id}", attestationId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.setAttestationResult(
|
||||||
|
token: String,
|
||||||
|
attestationId: String,
|
||||||
|
payload: AttestationResultPayload,
|
||||||
|
): AttestationDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/attestations/{id}/results", attestationId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(payload)
|
||||||
|
.retrieve()
|
||||||
|
.body(AttestationDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.setAttestationPublication(
|
||||||
|
token: String,
|
||||||
|
attestationId: String,
|
||||||
|
payload: AttestationPublicationPayload,
|
||||||
|
): AttestationDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/attestations/{id}/publication", attestationId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(payload)
|
||||||
|
.retrieve()
|
||||||
|
.body(AttestationDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.myAttestations(
|
||||||
|
token: String,
|
||||||
|
groupId: String,
|
||||||
|
): List<AttestationDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
|
||||||
|
return call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/me/attestations?groupId={gid}", groupId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.body(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,6 +264,38 @@ data class GradeDto(
|
|||||||
val generalComment: String = "",
|
val generalComment: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class AttestationResultDto(
|
||||||
|
val studentId: String = "",
|
||||||
|
val value: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class AttestationDto(
|
||||||
|
val id: String = "",
|
||||||
|
val groupId: String = "",
|
||||||
|
val kind: String = "",
|
||||||
|
/** Строка — чтобы старые ответы с текстовым семестром не ломали разбор. */
|
||||||
|
val semester: String = "1",
|
||||||
|
val published: Boolean = false,
|
||||||
|
val results: List<AttestationResultDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationCreatePayload(
|
||||||
|
val groupId: String,
|
||||||
|
val kind: String,
|
||||||
|
val semester: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationResultPayload(
|
||||||
|
val studentId: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttestationPublicationPayload(
|
||||||
|
val published: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class CommentDto(
|
data class CommentDto(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ 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("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(
|
||||||
|
|||||||
@@ -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,13 @@ class StudentController(
|
|||||||
val submissionCount: Int,
|
val submissionCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class StudentAttestationView(
|
||||||
|
val kindLabel: String,
|
||||||
|
val semester: Int,
|
||||||
|
val valueLabel: String,
|
||||||
|
val positive: Boolean?,
|
||||||
|
)
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun dashboard(
|
fun dashboard(
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
@@ -72,6 +81,25 @@ 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 = AttestationPresentation.semesterNumber(column.semester),
|
||||||
|
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
|
||||||
|
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
return "student/dashboard"
|
return "student/dashboard"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
|
|||||||
.variant-btn.manual .cell-none { font-weight: 600; }
|
.variant-btn.manual .cell-none { font-weight: 600; }
|
||||||
.score-link {
|
.score-link {
|
||||||
display: inline-block; padding: 0; border: none; background: none;
|
display: inline-block; padding: 0; border: none; background: none;
|
||||||
font-size: 0.8125rem; font-weight: 600; font-variant-numeric: tabular-nums;
|
font-size: 0.84375rem; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||||
color: var(--c-text); cursor: pointer;
|
color: var(--c-text); cursor: pointer;
|
||||||
}
|
}
|
||||||
.score-link:hover { color: var(--c-accent); text-decoration: underline; }
|
.score-link:hover { color: var(--c-accent); text-decoration: underline; }
|
||||||
@@ -495,7 +495,12 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami
|
|||||||
.cell-btn.st.dim::before { background: var(--c-border-strong); }
|
.cell-btn.st.dim::before { background: var(--c-border-strong); }
|
||||||
.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); }
|
.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); }
|
||||||
.defense-cell-timer { font-variant-numeric: tabular-nums; }
|
.defense-cell-timer { font-variant-numeric: tabular-nums; }
|
||||||
.lab-head-actions { display: flex; gap: 0.2rem; }
|
.lab-head-actions { display: flex; gap: 0.2rem; align-items: center; }
|
||||||
|
.lab-head-actions .btn {
|
||||||
|
height: 2.25rem; min-height: 2.25rem; padding: 0 0.5rem;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.lab-head-actions .ico { font-size: 0.95rem; line-height: 1; }
|
||||||
.btn btn-secondary btn-sm p-2 {
|
.btn btn-secondary btn-sm p-2 {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer;
|
min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer;
|
||||||
@@ -546,18 +551,72 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; }
|
|||||||
.scroll-x { overflow-x: auto; }
|
.scroll-x { overflow-x: auto; }
|
||||||
.matrix { border-collapse: separate; border-spacing: 0; }
|
.matrix { border-collapse: separate; border-spacing: 0; }
|
||||||
.matrix th {
|
.matrix th {
|
||||||
vertical-align: top; padding: 0.75rem 1rem; text-align: left;
|
vertical-align: middle; padding: 0.75rem 1rem; text-align: left;
|
||||||
font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted);
|
font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted);
|
||||||
background: var(--c-surface); white-space: nowrap;
|
background: var(--c-surface); white-space: nowrap;
|
||||||
}
|
}
|
||||||
.matrix td { vertical-align: top; padding: 1rem; }
|
.matrix td { vertical-align: middle; padding: 1rem; font-size: 0.84375rem; }
|
||||||
|
#group-table td .student-name,
|
||||||
|
#group-table td .score-link,
|
||||||
|
#group-table td .attestation-select,
|
||||||
|
#group-table td .cell-sub,
|
||||||
|
#group-table td .sub-link,
|
||||||
|
#group-table td .cell-btn.st,
|
||||||
|
#group-table td .font-mono,
|
||||||
|
#group-table td .mini-count,
|
||||||
|
#group-table td .badge,
|
||||||
|
#group-table td .btn { font-size: 0.84375rem; }
|
||||||
/* Непрозрачный фон обязателен: закреплённый столбец не должен просвечивать при прокрутке. */
|
/* Непрозрачный фон обязателен: закреплённый столбец не должен просвечивать при прокрутке. */
|
||||||
.matrix tbody tr:hover td { background: var(--c-row-hover); }
|
.matrix tbody tr:hover td { background: var(--c-row-hover); }
|
||||||
.matrix tbody tr:hover td.matrix-sticky { background: var(--c-row-hover); }
|
.matrix tbody tr:hover td.matrix-sticky { background: var(--c-row-hover); }
|
||||||
.matrix tbody tr:last-child td { border-bottom: none; }
|
.matrix tbody tr:last-child td { border-bottom: none; }
|
||||||
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||||
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||||
|
#group-table th.average-col,
|
||||||
|
#group-table th.actions-head { color: var(--c-text); font-weight: 400; }
|
||||||
|
#group-table th.actions-head,
|
||||||
|
#group-table td.actions-cell { border-left: 1px solid var(--c-border); }
|
||||||
|
.matrix th.attestation-col {
|
||||||
|
width: 12rem; min-width: 11.5rem; max-width: 12rem;
|
||||||
|
border-left: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.matrix td.attestation-cell {
|
||||||
|
width: 12rem; min-width: 11.5rem; max-width: 12rem;
|
||||||
|
padding-left: 0.35rem; padding-right: 0.35rem;
|
||||||
|
border-left: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.matrix th.average-col,
|
||||||
|
.matrix td.average-cell {
|
||||||
|
min-width: 7rem; border-left: 1px solid var(--c-border); text-align: left;
|
||||||
|
}
|
||||||
|
.matrix td.average-cell { font-weight: 600; text-align: center; }
|
||||||
|
.attestation-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; width: 100%; max-width: 9rem; text-align: left; }
|
||||||
|
.attestation-kind { font-weight: 400; color: var(--c-text); white-space: normal; text-align: left; }
|
||||||
|
.attestation-col .lab-col-head, .attestation-col .lab-col-title { text-align: left; }
|
||||||
|
.js-attestation-form { display: flex; justify-content: center; width: 100%; }
|
||||||
|
.attestation-select {
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
width: 100%; max-width: 100%; min-width: 0; padding: 0.15rem 0.95rem 0.15rem 0.15rem;
|
||||||
|
border: 1px solid transparent; border-radius: var(--radius-sm);
|
||||||
|
background-color: transparent; color: var(--c-text);
|
||||||
|
font-size: 0.84375rem; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center; text-align-last: center; cursor: pointer;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||||
|
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 8px) 55%, calc(100% - 3px) 55%;
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.attestation-select:hover { color: var(--c-accent); text-decoration: underline; }
|
||||||
|
.attestation-select:focus { outline: none; border-color: var(--c-accent); box-shadow: 0 0 0 3px var(--c-accent-soft); }
|
||||||
|
.attestation-select.none { color: var(--c-text-muted); font-weight: 400; }
|
||||||
|
.attestation-select:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.attestation-select option { text-align: left; text-align-last: left; background: var(--c-surface); color: var(--c-text); }
|
||||||
.matrix td.student-cell { min-width: 12rem; }
|
.matrix td.student-cell { min-width: 12rem; }
|
||||||
|
.student-no-line { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.password-dot { width: 6px; height: 6px; flex: 0 0 auto; background: var(--c-tone-pos); }
|
||||||
|
.password-dot.warn { background: #b45309; }
|
||||||
.student-name { font-weight: 600; font-size: 0.84375rem; }
|
.student-name { font-weight: 600; font-size: 0.84375rem; }
|
||||||
.student-cell .font-mono { margin-top: 0.125rem; }
|
.student-cell .font-mono { margin-top: 0.125rem; }
|
||||||
.lab-col-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }
|
.lab-col-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }
|
||||||
|
|||||||
@@ -208,43 +208,7 @@ function attachCommentAutocomplete(input) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */
|
/* ---------- Удаление подсказки комментария ---------- */
|
||||||
|
|
||||||
function showToast(text, kind) {
|
|
||||||
// уведомления должны быть видны поверх открытой модалки: кладём их в сам <dialog>
|
|
||||||
// (элемент верхнего слоя), иначе они оказываются под ним
|
|
||||||
var dlg = document.querySelector('dialog[open]');
|
|
||||||
var host = null;
|
|
||||||
if (dlg) {
|
|
||||||
host = dlg.querySelector('.toast-host');
|
|
||||||
if (!host) {
|
|
||||||
host = document.createElement('div');
|
|
||||||
host.className = 'toast-host';
|
|
||||||
dlg.appendChild(host);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
host = $id('toast-host');
|
|
||||||
}
|
|
||||||
if (!host) return;
|
|
||||||
var toast = document.createElement('div');
|
|
||||||
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
|
|
||||||
toast.setAttribute('data-toast', '');
|
|
||||||
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'toast-text';
|
|
||||||
span.textContent = text;
|
|
||||||
toast.appendChild(span);
|
|
||||||
|
|
||||||
var close = document.createElement('button');
|
|
||||||
close.type = 'button';
|
|
||||||
close.className = 'toast-close';
|
|
||||||
close.textContent = '✕';
|
|
||||||
close.addEventListener('click', function () { toast.remove(); });
|
|
||||||
toast.appendChild(close);
|
|
||||||
|
|
||||||
host.appendChild(toast);
|
|
||||||
setTimeout(function () { toast.remove(); }, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCommentTemplate(text, done) {
|
function removeCommentTemplate(text, done) {
|
||||||
var form = $id('grade-form');
|
var form = $id('grade-form');
|
||||||
@@ -345,6 +309,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 +388,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 +416,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 +589,211 @@ function focusLab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Оценка: отправка без перезагрузки страницы ----------
|
||||||
|
* Модалка не закрывается при ошибке, значения остаются на месте,
|
||||||
|
* позиция прокрутки страницы не меняется. */
|
||||||
|
function submitGradeForm(form) {
|
||||||
|
if (form.dataset.saving === '1') return;
|
||||||
|
form.dataset.saving = '1';
|
||||||
|
var btn = form.querySelector('button[type="submit"]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then(function (html) {
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
// Сервер вернул форму с ошибкой — показываем её без перезагрузки.
|
||||||
|
var feedbackNode = doc.getElementById('form-feedback');
|
||||||
|
if (feedbackNode) {
|
||||||
|
var feedback = null;
|
||||||
|
try {
|
||||||
|
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
|
||||||
|
} catch (e) {
|
||||||
|
feedback = null;
|
||||||
|
}
|
||||||
|
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
|
||||||
|
window.lmsApplyFormFeedback(feedback);
|
||||||
|
}
|
||||||
|
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
showToast(errorToast
|
||||||
|
? errorToast.textContent.trim()
|
||||||
|
: ((feedback && feedback.general) || 'Не удалось сохранить оценку'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var student = form.querySelector('[name="studentId"]').value;
|
||||||
|
var lab = form.querySelector('[name="labId"]').value;
|
||||||
|
var fresh = doc.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
|
||||||
|
var current = document.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
|
||||||
|
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
|
||||||
|
var errorWithoutFeedback = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
|
||||||
|
if (errorWithoutFeedback) {
|
||||||
|
showToast(errorWithoutFeedback.textContent.trim(), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fresh && !notice) {
|
||||||
|
showToast('Не удалось сохранить оценку. Обновите страницу и попробуйте ещё раз.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fresh && current) {
|
||||||
|
['data-score', 'data-general-comment', 'data-comments', 'title'].forEach(function (attr) {
|
||||||
|
if (fresh.hasAttribute(attr)) current.setAttribute(attr, fresh.getAttribute(attr));
|
||||||
|
else current.removeAttribute(attr);
|
||||||
|
});
|
||||||
|
var freshLabel = fresh.querySelector('span');
|
||||||
|
var currentLabel = current.querySelector('span');
|
||||||
|
if (freshLabel && currentLabel) currentLabel.textContent = freshLabel.textContent;
|
||||||
|
current.classList.toggle('none', fresh.classList.contains('none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var openDialog = form.closest('dialog');
|
||||||
|
if (openDialog) {
|
||||||
|
var staleToasts = openDialog.querySelector('.toast-host');
|
||||||
|
if (staleToasts) staleToasts.remove();
|
||||||
|
}
|
||||||
|
if (window.closeModal) window.closeModal('modal-grade');
|
||||||
|
showToast(notice ? notice.textContent.trim() : 'Оценка сохранена', 'success');
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
showToast('Не удалось сохранить оценку: ' + e.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
form.dataset.saving = '';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Аттестация: сохранение прямо из ячейки ---------- */
|
||||||
|
function submitAttestationForm(form) {
|
||||||
|
if (form.dataset.saving === '1') return;
|
||||||
|
form.dataset.saving = '1';
|
||||||
|
var select = form.querySelector('select[name="value"]');
|
||||||
|
var payload = new FormData(form);
|
||||||
|
if (select) select.disabled = true;
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (response.redirected && new URL(response.url, location.href).pathname === '/login') {
|
||||||
|
window.location.assign(response.url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(function (html) {
|
||||||
|
if (!html) return;
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
var feedbackNode = doc.getElementById('form-feedback');
|
||||||
|
if (feedbackNode) {
|
||||||
|
var feedback = null;
|
||||||
|
try {
|
||||||
|
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
|
||||||
|
} catch (e) {
|
||||||
|
feedback = null;
|
||||||
|
}
|
||||||
|
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
|
||||||
|
window.lmsApplyFormFeedback(feedback);
|
||||||
|
}
|
||||||
|
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
showToast(errorToast
|
||||||
|
? errorToast.textContent.trim()
|
||||||
|
: ((feedback && feedback.general) || 'Не удалось сохранить результат'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var error = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
if (error) {
|
||||||
|
showToast(error.textContent.trim(), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
|
||||||
|
showToast(notice ? notice.textContent.trim() : 'Результат аттестации сохранён', 'success');
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
showToast('Не удалось сохранить результат: ' + error.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
form.dataset.saving = '';
|
||||||
|
if (select) select.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('submit', function (ev) {
|
document.addEventListener('submit', function (ev) {
|
||||||
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
var form = ev.target;
|
||||||
|
if (!form || !form.classList || !form.classList.contains('js-attestation-form')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
submitAttestationForm(form);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', function () {
|
/* ---------- Показ/скрытие столбца аттестации ---------- */
|
||||||
|
document.addEventListener('submit', function (ev) {
|
||||||
|
var form = ev.target;
|
||||||
|
if (!form || !form.classList || !form.classList.contains('js-att-pub-form')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var button = form.querySelector('button[type="submit"]');
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
showToast((result && result.error) || 'Не удалось изменить публикацию аттестации', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var published = !!result.published;
|
||||||
|
var hidden = form.querySelector('input[name="published"]');
|
||||||
|
if (hidden) hidden.value = published ? 'false' : 'true';
|
||||||
|
if (button) {
|
||||||
|
button.classList.toggle('is-all', published);
|
||||||
|
button.classList.toggle('is-empty', !published);
|
||||||
|
button.title = published
|
||||||
|
? 'Аттестация видна студентам — скрыть'
|
||||||
|
: 'Аттестация скрыта — показать студентам';
|
||||||
|
}
|
||||||
|
showToast(published ? 'Аттестация открыта студентам' : 'Аттестация скрыта от студентов', 'success');
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
showToast('Не удалось изменить публикацию: ' + error.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
if (button) button.disabled = false;
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('submit', function (ev) {
|
||||||
|
if (!ev.target || ev.target.id !== 'grade-form') return;
|
||||||
|
csSerialize();
|
||||||
|
ev.preventDefault();
|
||||||
|
submitGradeForm(ev.target);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function initGroupPage() {
|
||||||
focusLab();
|
focusLab();
|
||||||
startDefenseWatch();
|
startDefenseWatch();
|
||||||
attachPublishToggles();
|
attachPublishToggles();
|
||||||
});
|
}
|
||||||
|
window.__lmsInitGroupPage = initGroupPage;
|
||||||
|
window.addEventListener('DOMContentLoaded', initGroupPage);
|
||||||
|
|
||||||
/* ---------- Большая модалка «Выдача вариантов» ---------- */
|
/* ---------- Большая модалка «Выдача вариантов» ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
227
web/src/main/resources/static/js/modal-ajax.js
Normal file
227
web/src/main/resources/static/js/modal-ajax.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,23 +16,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4 mt-4">
|
<div class="row g-4 mt-4">
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="section-title m-0">Лабораторные</h2>
|
|
||||||
<a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
|
|
||||||
</div>
|
|
||||||
<div class="card card-flush">
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="lab : ${labs}">
|
|
||||||
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
|
|
||||||
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="section-title m-0">Группы</h2>
|
<h2 class="section-title m-0">Группы</h2>
|
||||||
@@ -50,6 +33,23 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="section-title m-0">Лабораторные</h2>
|
||||||
|
<a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="lab : ${labs}">
|
||||||
|
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
|
||||||
|
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
|
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
|
||||||
|
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
|
||||||
<button class="btn" 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 +33,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,15 +73,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th data-nosort>Пароль</th>
|
<th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
|
||||||
<th class="text-right" data-nosort>Действия</th>
|
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
|
||||||
|
<div class="lab-col-head">
|
||||||
|
<div class="attestation-title">
|
||||||
|
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
|
||||||
|
</div>
|
||||||
|
<span class="lab-col-title" th:text="'Семестр ' + ${att.semester}">Семестр 1</span>
|
||||||
|
<div class="lab-head-actions">
|
||||||
|
<form class="inline-form js-att-pub-form" method="post"
|
||||||
|
th:action="@{/admin/groups/{gid}/attestations/{aid}/publication(gid=${group.id}, aid=${att.id})}">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="published" th:value="${!att.published}">
|
||||||
|
<button class="btn btn-secondary btn-sm p-2 js-pub-eye js-att-pub" type="submit"
|
||||||
|
th:classappend="${att.published} ? 'is-all' : 'is-empty'"
|
||||||
|
th:title="${att.published} ? 'Аттестация видна студентам — скрыть' : 'Аттестация скрыта — показать студентам'">
|
||||||
|
<i class="eye-open ico bi bi-eye" aria-hidden="true"></i>
|
||||||
|
<i class="eye-closed ico bi bi-eye-slash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-form"
|
||||||
|
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
|
||||||
|
method="post"
|
||||||
|
data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<button class="btn btn-danger btn-sm p-2" type="submit"
|
||||||
|
title="Удалить аттестацию" aria-label="Удалить аттестацию">
|
||||||
|
<i class="cell-ico ico bi bi-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-right actions-head" data-nosort>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="row : ${studentGrades}">
|
<tr th:each="row : ${studentGrades}">
|
||||||
<td class="student-cell matrix-sticky">
|
<td class="student-cell matrix-sticky">
|
||||||
<div class="student-name" th:text="${row.fullName}">—</div>
|
<div class="student-name" th:text="${row.fullName}">—</div>
|
||||||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
<div class="student-no-line">
|
||||||
|
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
|
||||||
|
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
||||||
|
aria-label="Статус пароля"></span>
|
||||||
|
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
||||||
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
||||||
@@ -120,9 +159,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="average-cell"
|
||||||
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
|
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
|
||||||
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
|
<td th:each="att : ${attestations}" class="attestation-cell">
|
||||||
|
<form class="inline-form js-attestation-form" method="post"
|
||||||
|
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="attestationId" th:value="${att.id}">
|
||||||
|
<input type="hidden" name="studentId" th:value="${row.studentId}">
|
||||||
|
<select class="attestation-select" name="value" data-auto-submit
|
||||||
|
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
|
||||||
|
th:title="${row.fullName} + ' — ' + ${att.kindLabel} + ' (' + 'Семестр ' + ${att.semester} + ')'">
|
||||||
|
<option value="" th:selected="${row.attestations[att.id] == null}">—</option>
|
||||||
|
<option th:each="opt : ${att.options}" th:value="${opt.value}" th:text="${opt.label}"
|
||||||
|
th:selected="${row.attestations[att.id]?.value == opt.value}"></option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right actions-cell">
|
<td class="text-right actions-cell">
|
||||||
<div class="actions-col">
|
<div class="actions-col">
|
||||||
@@ -139,7 +191,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) + 3}">В группе нет студентов.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +211,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 +225,40 @@
|
|||||||
</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>
|
||||||
|
<select class="input" id="attestation-semester" name="semester" required
|
||||||
|
data-error-required="Укажите семестр от 1 до 8">
|
||||||
|
<option value="1">Семестр 1</option>
|
||||||
|
<option value="2">Семестр 2</option>
|
||||||
|
<option value="3">Семестр 3</option>
|
||||||
|
<option value="4">Семестр 4</option>
|
||||||
|
<option value="5">Семестр 5</option>
|
||||||
|
<option value="6">Семестр 6</option>
|
||||||
|
<option value="7">Семестр 7</option>
|
||||||
|
<option value="8">Семестр 8</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Номер семестра от 1 до 8. Он будет указан в ведомости и кабинете студента.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Добавить столбец</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- Диалог: индивидуальное задание (защита) -->
|
<!-- Диалог: индивидуальное задание (защита) -->
|
||||||
<!-- Диалог: назначение варианта -->
|
<!-- Диалог: назначение варианта -->
|
||||||
<dialog id="modal-variant" class="dlg dlg-lg">
|
<dialog id="modal-variant" class="dlg dlg-lg">
|
||||||
@@ -277,7 +364,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 +433,7 @@
|
|||||||
<div class="dlg-body" id="assign-modal-body"></div>
|
<div class="dlg-body" id="assign-modal-body"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script th:src="@{/js/admin-group.js(v='37')}" defer></script>
|
<script th:src="@{/js/admin-group.js(v='43')}" defer></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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='52')}">
|
||||||
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
|
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
|
||||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
|
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
|
||||||
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
|
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
|
||||||
<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='40')}" defer></script>
|
||||||
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
||||||
<script th:src="@{/js/form-feedback.js(v='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>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
<div class="card card-flush">
|
<div class="card card-flush">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
|
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort th:if="${currentUser != null and currentUser.admin}">Действия</th></tr></thead>
|
||||||
<tbody data-article-list>
|
<tbody data-article-list>
|
||||||
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||||||
<td>
|
<td>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
th:text="${article.title}">—</a>
|
th:text="${article.title}">—</a>
|
||||||
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
|
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right actions-cell">
|
<td class="text-right actions-cell" th:if="${currentUser != null and currentUser.admin}">
|
||||||
<a class="btn btn-secondary btn-sm p-2" title="Править статью" aria-label="Править статью"
|
<a class="btn btn-secondary btn-sm p-2" title="Править статью" aria-label="Править статью"
|
||||||
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}"><i class="ico bi bi-pencil" aria-hidden="true"></i></a>
|
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}"><i class="ico bi bi-pencil" aria-hidden="true"></i></a>
|
||||||
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(section.articles)}"><td colspan="2" class="empty">Статей пока нет.</td></tr>
|
<tr th:if="${#lists.isEmpty(section.articles)}"><td th:colspan="${currentUser != null and currentUser.admin} ? 2 : 1" class="empty">Статей пока нет.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="modal-new-article" class="dlg">
|
<dialog id="modal-new-article" class="dlg" th:if="${currentUser != null and currentUser.admin}">
|
||||||
<div class="dlg-header">
|
<div class="dlg-header">
|
||||||
<h2 class="dlg-title">Новая статья в разделе</h2>
|
<h2 class="dlg-title">Новая статья в разделе</h2>
|
||||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||||
@@ -143,7 +143,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
|
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
|
||||||
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -24,6 +24,29 @@
|
|||||||
<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></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="a : ${attestations}">
|
||||||
|
<td th:text="${a.kindLabel}">—</td>
|
||||||
|
<td th:text="'Семестр ' + ${a.semester}">—</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge"
|
||||||
|
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
|
||||||
|
th:text="${a.valueLabel}">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div th:if="${#lists.isEmpty(attestations)}" class="card card-body text-muted mb-6">
|
||||||
|
Аттестаций пока нет. Преподаватель добавит результаты после их выставления.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title mt-8">Мои лабораторные</h2>
|
<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">
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal file
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package ru.ysu.lms.web.learn
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse
|
||||||
|
import org.springframework.mock.web.MockServletContext
|
||||||
|
import org.thymeleaf.context.WebContext
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine
|
||||||
|
import org.thymeleaf.templatemode.TemplateMode
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||||
|
import org.thymeleaf.web.servlet.JakartaServletWebApplication
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class LearnSectionTemplateTest {
|
||||||
|
private val engine =
|
||||||
|
SpringTemplateEngine().apply {
|
||||||
|
setTemplateResolver(
|
||||||
|
ClassLoaderTemplateResolver().apply {
|
||||||
|
prefix = "templates/"
|
||||||
|
suffix = ".html"
|
||||||
|
characterEncoding = "UTF-8"
|
||||||
|
templateMode = TemplateMode.HTML
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student does not see article admin actions`() {
|
||||||
|
val html = render("STUDENT")
|
||||||
|
|
||||||
|
assertThat(html).contains("Статья 1")
|
||||||
|
assertThat(html).doesNotContain("Удалить статью")
|
||||||
|
assertThat(html).doesNotContain("Править статью")
|
||||||
|
assertThat(html).doesNotContain("Скрыть статью")
|
||||||
|
assertThat(html).doesNotContain("modal-new-article")
|
||||||
|
assertThat(html).doesNotContain(">Действия</th>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin sees article actions`() {
|
||||||
|
val html = render("ADMIN")
|
||||||
|
|
||||||
|
assertThat(html).contains("Статья 1")
|
||||||
|
assertThat(html).contains("Удалить статью")
|
||||||
|
assertThat(html).contains("Править статью")
|
||||||
|
assertThat(html).contains("Скрыть статью")
|
||||||
|
assertThat(html).contains("modal-new-article")
|
||||||
|
assertThat(html).contains(">Действия</th>")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(role: String): String {
|
||||||
|
val servletContext = MockServletContext()
|
||||||
|
val application = JakartaServletWebApplication.buildApplication(servletContext)
|
||||||
|
val exchange =
|
||||||
|
application.buildExchange(
|
||||||
|
MockHttpServletRequest(servletContext),
|
||||||
|
MockHttpServletResponse(),
|
||||||
|
)
|
||||||
|
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
|
||||||
|
context.setVariable("csrfToken", "csrf")
|
||||||
|
context.setVariable(
|
||||||
|
"currentUser",
|
||||||
|
SessionUser(
|
||||||
|
id = "user-1",
|
||||||
|
login = role.lowercase(),
|
||||||
|
role = role,
|
||||||
|
name = "Пользователь",
|
||||||
|
mustChangePassword = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context.setVariable(
|
||||||
|
"chapter",
|
||||||
|
mapOf(
|
||||||
|
"slug" to "chapter-1",
|
||||||
|
"title" to "Глава",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context.setVariable(
|
||||||
|
"section",
|
||||||
|
mapOf(
|
||||||
|
"id" to "section-1",
|
||||||
|
"slug" to "section-1",
|
||||||
|
"title" to "Раздел",
|
||||||
|
"published" to true,
|
||||||
|
"articles" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"id" to "article-1",
|
||||||
|
"title" to "Статья 1",
|
||||||
|
"slug" to "article-1",
|
||||||
|
"published" to true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context.setVariable("back", "/learn/chapter-1")
|
||||||
|
context.setVariable("backParent", "/learn")
|
||||||
|
context.setVariable(
|
||||||
|
"sectionChoices",
|
||||||
|
listOf(mapOf("id" to "section-2", "chapterId" to "chapter-1", "label" to "Глава / Раздел 2")),
|
||||||
|
)
|
||||||
|
return engine.process("learn/section", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user