feat: add course attestations and average grade column
- store attestation columns and per-student results in progress-service - add teacher UI for intermediate/pass/exam columns with semester and missed hours - show average score per student on the group page - show attestation results on the student dashboard
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package ru.ysu.lms.web
|
||||
|
||||
/** Подписи типов и оценок аттестации для интерфейса. */
|
||||
object AttestationPresentation {
|
||||
private val KIND_LABELS =
|
||||
mapOf(
|
||||
"intermediate" to "Промежуточная аттестация",
|
||||
"pass" to "Зачёт",
|
||||
"exam" to "Экзамен",
|
||||
)
|
||||
|
||||
private val VALUE_LABELS =
|
||||
mapOf(
|
||||
"intermediate" to
|
||||
mapOf(
|
||||
"excellent" to "отл",
|
||||
"satisfactory" to "атт",
|
||||
"unsatisfactory" to "неуд",
|
||||
"not_attested" to "не атт",
|
||||
),
|
||||
"pass" to
|
||||
mapOf(
|
||||
"passed" to "зачтено",
|
||||
"failed" to "не зачтено",
|
||||
"absent" to "неявка",
|
||||
),
|
||||
"exam" to
|
||||
mapOf(
|
||||
"excellent" to "отлично",
|
||||
"good" to "хорошо",
|
||||
"satisfactory" to "удовлетворительно",
|
||||
"unsatisfactory" to "неудовлетворительно",
|
||||
"absent" to "неявка",
|
||||
),
|
||||
)
|
||||
|
||||
private val POSITIVE_VALUES =
|
||||
mapOf(
|
||||
"intermediate" to setOf("excellent", "satisfactory"),
|
||||
"pass" to setOf("passed"),
|
||||
"exam" to setOf("excellent", "good", "satisfactory"),
|
||||
)
|
||||
|
||||
fun kindLabel(kind: String): String = KIND_LABELS[kind] ?: kind
|
||||
|
||||
fun valueLabel(
|
||||
kind: String,
|
||||
value: String,
|
||||
): String = VALUE_LABELS[kind]?.get(value) ?: value
|
||||
|
||||
fun isPositive(
|
||||
kind: String,
|
||||
value: String,
|
||||
): Boolean? = if (value.isBlank()) null else POSITIVE_VALUES[kind]?.contains(value) == true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ru.ysu.lms.web.admin
|
||||
|
||||
import jakarta.servlet.http.HttpSession
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.web.bind.annotation.ModelAttribute
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
import ru.ysu.lms.web.api.ApiException
|
||||
import ru.ysu.lms.web.api.AttestationCreatePayload
|
||||
import ru.ysu.lms.web.api.AttestationResultPayload
|
||||
import ru.ysu.lms.web.api.createAttestation
|
||||
import ru.ysu.lms.web.api.deleteAttestation
|
||||
import ru.ysu.lms.web.api.setAttestationResult
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
class AdminAttestationController(
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
@PostMapping("/groups/{id}/attestations")
|
||||
fun createAttestation(
|
||||
@PathVariable id: String,
|
||||
@ModelAttribute form: CreateAttestationForm,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
val semester = form.semester.trim()
|
||||
if (semester.isBlank()) throw ApiException(400, "Укажите семестр")
|
||||
api.createAttestation(
|
||||
token(session),
|
||||
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Столбец аттестации добавлен")
|
||||
"redirect:/admin/groups/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{id}/attestations/{attestationId}/delete")
|
||||
fun deleteAttestation(
|
||||
@PathVariable id: String,
|
||||
@PathVariable attestationId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.deleteAttestation(token(session), attestationId)
|
||||
redirect.addFlashAttribute("notice", "Столбец аттестации удалён")
|
||||
"redirect:/admin/groups/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{id}/attestation-results")
|
||||
fun setAttestationResult(
|
||||
@PathVariable id: String,
|
||||
@ModelAttribute form: SetAttestationResultForm,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.setAttestationResult(
|
||||
token(session),
|
||||
form.attestationId,
|
||||
AttestationResultPayload(
|
||||
studentId = form.studentId,
|
||||
value = form.value,
|
||||
missedHours = parseMissedHours(form.missedHours),
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
|
||||
"redirect:/admin/groups/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
}
|
||||
|
||||
private fun parseMissedHours(raw: String?): Int? {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым")
|
||||
if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным")
|
||||
return value
|
||||
}
|
||||
|
||||
private fun token(session: HttpSession): String =
|
||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||
}
|
||||
@@ -85,6 +85,18 @@ data class SetGradeForm(
|
||||
val generalComment: String = "",
|
||||
)
|
||||
|
||||
data class CreateAttestationForm(
|
||||
val kind: String = "",
|
||||
val semester: String = "",
|
||||
)
|
||||
|
||||
data class SetAttestationResultForm(
|
||||
val attestationId: String = "",
|
||||
val studentId: String = "",
|
||||
val value: String = "",
|
||||
val missedHours: String? = null,
|
||||
)
|
||||
|
||||
data class SubmissionDownloadForm(
|
||||
val group: String? = null,
|
||||
val no: String? = null,
|
||||
|
||||
@@ -38,6 +38,7 @@ import ru.ysu.lms.web.api.groupLabData
|
||||
import ru.ysu.lms.web.api.importGroup
|
||||
import ru.ysu.lms.web.api.labDetail
|
||||
import ru.ysu.lms.web.api.labSubmissions
|
||||
import ru.ysu.lms.web.api.listAttestations
|
||||
import ru.ysu.lms.web.api.listGrades
|
||||
import ru.ysu.lms.web.api.listGroups
|
||||
import ru.ysu.lms.web.api.listLabs
|
||||
@@ -46,6 +47,8 @@ import ru.ysu.lms.web.api.setGrade
|
||||
import ru.ysu.lms.web.api.setPublication
|
||||
import ru.ysu.lms.web.api.suggestComments
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@@ -137,7 +140,11 @@ class AdminGroupController(
|
||||
model: Model,
|
||||
): String {
|
||||
val t = token(session)
|
||||
val (group, labs) = loadGroupAndLabs(t, id)
|
||||
val allLabs = api.listLabs(t)
|
||||
val group = api.groupDetail(t, id)
|
||||
val labs = allLabs.filter { it.status == "published" }
|
||||
val attestations = GroupAttestationViews.columns(api.listAttestations(t, id))
|
||||
val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations)
|
||||
val grades = api.listGrades(t)
|
||||
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
|
||||
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
|
||||
@@ -166,19 +173,29 @@ class AdminGroupController(
|
||||
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
|
||||
|
||||
val rows =
|
||||
buildRows(
|
||||
group.students,
|
||||
labs,
|
||||
gradeBy,
|
||||
generalCommentBy,
|
||||
commentsBy,
|
||||
publishedByLab,
|
||||
assignmentsBy,
|
||||
submissionsBy,
|
||||
defensesBy,
|
||||
GroupAttestationViews.attach(
|
||||
buildRows(
|
||||
group.students,
|
||||
labs,
|
||||
gradeBy,
|
||||
generalCommentBy,
|
||||
commentsBy,
|
||||
publishedByLab,
|
||||
assignmentsBy,
|
||||
submissionsBy,
|
||||
defensesBy,
|
||||
),
|
||||
attestations,
|
||||
)
|
||||
|
||||
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy)))
|
||||
fillGroupModel(
|
||||
model,
|
||||
groupViewModel(
|
||||
t,
|
||||
id,
|
||||
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters),
|
||||
),
|
||||
)
|
||||
return "admin/group"
|
||||
}
|
||||
|
||||
@@ -222,6 +239,7 @@ class AdminGroupController(
|
||||
defenseAssignmentMd = def?.assignmentMd,
|
||||
)
|
||||
}
|
||||
val averageScore = averageScore(cells)
|
||||
GroupStudentRow(
|
||||
studentId = s.id,
|
||||
fullName = s.fullName,
|
||||
@@ -239,13 +257,19 @@ class AdminGroupController(
|
||||
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
|
||||
},
|
||||
),
|
||||
averageScore = averageScore,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadGroupAndLabs(
|
||||
token: String,
|
||||
id: String,
|
||||
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
|
||||
private fun averageScore(cells: List<GroupLabCell>): String? {
|
||||
val scores = cells.mapNotNull { it.score?.replace(',', '.')?.toBigDecimalOrNull() }
|
||||
if (scores.isEmpty()) return null
|
||||
return scores
|
||||
.fold(BigDecimal.ZERO, BigDecimal::add)
|
||||
.divide(BigDecimal(scores.size), 2, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros()
|
||||
.toPlainString()
|
||||
}
|
||||
|
||||
private data class GroupRenderData(
|
||||
val group: GroupDetailDto,
|
||||
@@ -253,6 +277,8 @@ class AdminGroupController(
|
||||
val rows: List<GroupStudentRow>,
|
||||
val labPublication: Map<String, GroupLabPublication>,
|
||||
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
|
||||
val attestations: List<GroupAttestation>,
|
||||
val attestationSemesters: List<String>,
|
||||
)
|
||||
|
||||
private fun groupViewModel(
|
||||
@@ -269,7 +295,16 @@ class AdminGroupController(
|
||||
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
|
||||
}
|
||||
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
|
||||
return GroupViewModel(data.group, data.labs, candidates, data.rows, data.labPublication, submissionCounts)
|
||||
return GroupViewModel(
|
||||
data.group,
|
||||
data.labs,
|
||||
candidates,
|
||||
data.rows,
|
||||
data.labPublication,
|
||||
submissionCounts,
|
||||
data.attestations,
|
||||
data.attestationSemesters,
|
||||
)
|
||||
}
|
||||
|
||||
private data class GroupViewModel(
|
||||
@@ -279,6 +314,8 @@ class AdminGroupController(
|
||||
val rows: List<GroupStudentRow>,
|
||||
val labPublication: Map<String, GroupLabPublication>,
|
||||
val submissionCounts: Map<String, Int>,
|
||||
val attestations: List<GroupAttestation>,
|
||||
val attestationSemesters: List<String>,
|
||||
)
|
||||
|
||||
private fun fillGroupModel(
|
||||
@@ -291,6 +328,8 @@ class AdminGroupController(
|
||||
model.addAttribute("studentGrades", view.rows)
|
||||
model.addAttribute("labPublication", view.labPublication)
|
||||
model.addAttribute("labSubmissionCounts", view.submissionCounts)
|
||||
model.addAttribute("attestations", view.attestations)
|
||||
model.addAttribute("attestationSemesters", view.attestationSemesters)
|
||||
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,24 @@ data class GroupStudentRow(
|
||||
val cells: List<GroupLabCell>,
|
||||
val variantsSummary: String,
|
||||
val commentsJson: String,
|
||||
val averageScore: String? = null,
|
||||
val attestations: Map<String, GroupAttestationResult> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Результат студента по одному столбцу аттестации. */
|
||||
data class GroupAttestationResult(
|
||||
val value: String = "",
|
||||
val label: String = "",
|
||||
val missedHours: Int? = null,
|
||||
)
|
||||
|
||||
/** Столбец аттестации на странице группы. */
|
||||
data class GroupAttestation(
|
||||
val id: String = "",
|
||||
val kind: String = "",
|
||||
val kindLabel: String = "",
|
||||
val semester: String = "",
|
||||
val results: Map<String, GroupAttestationResult> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Ряд блока защит (одна сессия на строку). */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package ru.ysu.lms.web.admin
|
||||
|
||||
import ru.ysu.lms.web.AttestationPresentation
|
||||
import ru.ysu.lms.web.api.AttestationDto
|
||||
import ru.ysu.lms.web.api.LabSummaryDto
|
||||
|
||||
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */
|
||||
object GroupAttestationViews {
|
||||
fun columns(attestations: List<AttestationDto>): List<GroupAttestation> =
|
||||
attestations.map { column ->
|
||||
GroupAttestation(
|
||||
id = column.id,
|
||||
kind = column.kind,
|
||||
kindLabel = AttestationPresentation.kindLabel(column.kind),
|
||||
semester = column.semester,
|
||||
results =
|
||||
column.results.associate { row ->
|
||||
row.studentId to
|
||||
GroupAttestationResult(
|
||||
value = row.value,
|
||||
label = AttestationPresentation.valueLabel(column.kind, row.value),
|
||||
missedHours = row.missedHours,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun semesters(
|
||||
labs: List<LabSummaryDto>,
|
||||
attestations: List<GroupAttestation>,
|
||||
): List<String> =
|
||||
(labs.map { it.semesterTitle } + attestations.map { it.semester })
|
||||
.filter { it.isNotBlank() && it != "—" }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
fun attach(
|
||||
rows: List<GroupStudentRow>,
|
||||
columns: List<GroupAttestation>,
|
||||
): List<GroupStudentRow> =
|
||||
rows.map { row ->
|
||||
val byColumn = LinkedHashMap<String, GroupAttestationResult>()
|
||||
for (column in columns) {
|
||||
val result = column.results[row.studentId] ?: continue
|
||||
byColumn[column.id] = result
|
||||
}
|
||||
row.copy(attestations = byColumn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.ysu.lms.web.api
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
|
||||
fun ApiClient.listAttestations(
|
||||
token: String,
|
||||
groupId: String,
|
||||
): List<AttestationDto> {
|
||||
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
|
||||
return call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/attestations?groupId={gid}", groupId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(type)
|
||||
}
|
||||
}
|
||||
|
||||
fun ApiClient.createAttestation(
|
||||
token: String,
|
||||
payload: AttestationCreatePayload,
|
||||
): AttestationDto =
|
||||
call {
|
||||
client
|
||||
.post()
|
||||
.uri("/api/v1/attestations")
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(AttestationDto::class.java)
|
||||
}
|
||||
|
||||
fun ApiClient.deleteAttestation(
|
||||
token: String,
|
||||
attestationId: String,
|
||||
) {
|
||||
call {
|
||||
client
|
||||
.delete()
|
||||
.uri("/api/v1/attestations/{id}", attestationId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
}
|
||||
}
|
||||
|
||||
fun ApiClient.setAttestationResult(
|
||||
token: String,
|
||||
attestationId: String,
|
||||
payload: AttestationResultPayload,
|
||||
): AttestationDto =
|
||||
call {
|
||||
client
|
||||
.post()
|
||||
.uri("/api/v1/attestations/{id}/results", attestationId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(AttestationDto::class.java)
|
||||
}
|
||||
|
||||
fun ApiClient.myAttestations(
|
||||
token: String,
|
||||
groupId: String,
|
||||
): List<AttestationDto> {
|
||||
val type = object : ParameterizedTypeReference<List<AttestationDto>>() {}
|
||||
return call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/me/attestations?groupId={gid}", groupId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(type)
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,34 @@ data class GradeDto(
|
||||
val generalComment: String = "",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AttestationResultDto(
|
||||
val studentId: String = "",
|
||||
val value: String = "",
|
||||
val missedHours: Int? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AttestationDto(
|
||||
val id: String = "",
|
||||
val groupId: String = "",
|
||||
val kind: String = "",
|
||||
val semester: String = "",
|
||||
val results: List<AttestationResultDto> = emptyList(),
|
||||
)
|
||||
|
||||
data class AttestationCreatePayload(
|
||||
val groupId: String,
|
||||
val kind: String,
|
||||
val semester: String,
|
||||
)
|
||||
|
||||
data class AttestationResultPayload(
|
||||
val studentId: String,
|
||||
val value: String,
|
||||
val missedHours: Int? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CommentDto(
|
||||
val text: String = "",
|
||||
|
||||
@@ -54,7 +54,10 @@ data class FormFeedback(
|
||||
Rule(Regex("(?iu)номер лабораторной"), listOf("number")),
|
||||
Rule(Regex("(?iu)номер варианта"), listOf("number")),
|
||||
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
|
||||
Rule(Regex("(?iu)оценк"), listOf("score")),
|
||||
Rule(Regex("(?iu)оценк"), listOf("score", "value")),
|
||||
Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")),
|
||||
Rule(Regex("(?iu)семестр"), listOf("semester")),
|
||||
Rule(Regex("(?iu)тип аттестации"), listOf("kind")),
|
||||
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),
|
||||
Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")),
|
||||
Rule(
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseBody
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||
import ru.ysu.lms.web.AttestationPresentation
|
||||
import ru.ysu.lms.web.DateUtils
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
import ru.ysu.lms.web.api.ApiException
|
||||
@@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.finishDefense
|
||||
import ru.ysu.lms.web.api.labDetail
|
||||
import ru.ysu.lms.web.api.listLabs
|
||||
import ru.ysu.lms.web.api.meAssignments
|
||||
import ru.ysu.lms.web.api.myAttestations
|
||||
import ru.ysu.lms.web.api.myDefense
|
||||
import ru.ysu.lms.web.api.myDefenseStatus
|
||||
import ru.ysu.lms.web.api.myGrades
|
||||
@@ -59,6 +61,14 @@ class StudentController(
|
||||
val submissionCount: Int,
|
||||
)
|
||||
|
||||
data class StudentAttestationView(
|
||||
val kindLabel: String,
|
||||
val semester: String,
|
||||
val valueLabel: String,
|
||||
val positive: Boolean?,
|
||||
val missedHours: Int?,
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun dashboard(
|
||||
session: HttpSession,
|
||||
@@ -72,6 +82,26 @@ class StudentController(
|
||||
model.addAttribute("average", if (scored.isEmpty()) null else scored.averageFormat())
|
||||
model.addAttribute("best", scored.maxOrNull())
|
||||
model.addAttribute("worst", scored.minOrNull())
|
||||
val groupId = studentGroupId(t)
|
||||
val attestations =
|
||||
if (groupId == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching { api.myAttestations(t, groupId) }.getOrDefault(emptyList())
|
||||
}
|
||||
model.addAttribute(
|
||||
"attestations",
|
||||
attestations.map { column ->
|
||||
val result = column.results.firstOrNull()
|
||||
StudentAttestationView(
|
||||
kindLabel = AttestationPresentation.kindLabel(column.kind),
|
||||
semester = column.semester,
|
||||
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
|
||||
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
|
||||
missedHours = result?.missedHours,
|
||||
)
|
||||
},
|
||||
)
|
||||
return "student/dashboard"
|
||||
}
|
||||
|
||||
|
||||
@@ -557,6 +557,11 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; }
|
||||
.matrix tbody tr:last-child td { border-bottom: none; }
|
||||
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); font-weight: 600; }
|
||||
.attestation-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; text-align: left; }
|
||||
.attestation-kind { font-weight: 600; color: var(--c-text); }
|
||||
.matrix td.student-cell { min-width: 12rem; }
|
||||
.student-name { font-weight: 600; font-size: 0.84375rem; }
|
||||
.student-cell .font-mono { margin-top: 0.125rem; }
|
||||
|
||||
@@ -32,6 +32,59 @@ function openGradeEditor(btn) {
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
/* ---------- Аттестация: выбор результата ---------- */
|
||||
|
||||
var ATTESTATION_OPTIONS = {
|
||||
intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']],
|
||||
pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']],
|
||||
exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']]
|
||||
};
|
||||
|
||||
function openAttestation(btn) {
|
||||
var form = $id('attestation-form');
|
||||
if (!form) return;
|
||||
|
||||
var kind = btn.getAttribute('data-attestation-kind') || 'intermediate';
|
||||
var student = btn.getAttribute('data-student') || '';
|
||||
var studentName = btn.getAttribute('data-student-name') || '';
|
||||
var label = btn.getAttribute('data-attestation-label') || 'Аттестация';
|
||||
var semester = btn.getAttribute('data-semester') || '';
|
||||
var value = btn.getAttribute('data-attestation-value') || '';
|
||||
|
||||
$id('attestation-id').value = btn.getAttribute('data-attestation') || '';
|
||||
$id('attestation-student').value = student;
|
||||
$id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : '');
|
||||
|
||||
var select = $id('attestation-value');
|
||||
select.innerHTML = '';
|
||||
var empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = '— не выставлено —';
|
||||
select.appendChild(empty);
|
||||
(ATTESTATION_OPTIONS[kind] || []).forEach(function (item) {
|
||||
var option = document.createElement('option');
|
||||
option.value = item[0];
|
||||
option.textContent = item[1];
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = value;
|
||||
|
||||
var hours = $id('attestation-hours');
|
||||
var hoursWrap = $id('attestation-hours-wrap');
|
||||
var missed = btn.getAttribute('data-missed-hours');
|
||||
if (hours) hours.value = missed && missed !== 'null' ? missed : '';
|
||||
if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate';
|
||||
if (kind !== 'intermediate' && hours) hours.value = '';
|
||||
|
||||
var title = $id('attestation-title');
|
||||
if (title) title.textContent = label + ' — ' + studentName;
|
||||
|
||||
var dialog = $id('modal-attestation');
|
||||
if (dialog && dialog.showModal) dialog.showModal();
|
||||
}
|
||||
|
||||
window.openAttestation = openAttestation;
|
||||
|
||||
function csRow(text, tone) {
|
||||
var box = $id('cs-rows');
|
||||
var row = document.createElement('div');
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
var kind = action.getAttribute('data-action');
|
||||
var fn = {
|
||||
grade: window.openGradeEditor,
|
||||
attestation: window.openAttestation,
|
||||
variant: window.openVariant,
|
||||
defense: window.openDefense,
|
||||
assign: window.openLabAssign,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
|
||||
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
|
||||
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,10 +31,10 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||||
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||||
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
|
||||
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
|
||||
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
|
||||
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
|
||||
<div class="scroll-x">
|
||||
<table class="matrix tablekit" id="group-table">
|
||||
<thead>
|
||||
@@ -70,6 +71,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="text-center" title="Средний балл по выставленным оценкам">Средний балл</th>
|
||||
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
|
||||
<div class="attestation-head">
|
||||
<div class="lab-head-actions">
|
||||
<form class="inline-form"
|
||||
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
|
||||
method="post"
|
||||
data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-danger btn-sm p-2" type="submit"
|
||||
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
|
||||
<span class="lab-col-title" th:text="${att.semester}">—</span>
|
||||
</div>
|
||||
</th>
|
||||
<th data-nosort>Пароль</th>
|
||||
<th class="text-right" data-nosort>Действия</th>
|
||||
</tr>
|
||||
@@ -120,6 +138,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center font-mono average-cell"
|
||||
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
|
||||
<td th:each="att : ${attestations}" class="text-center attestation-cell">
|
||||
<button class="score-link" type="button"
|
||||
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
|
||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-attestation=${att.id},data-attestation-kind=${att.kind},data-attestation-value=${row.attestations[att.id]?.value},data-missed-hours=${row.attestations[att.id]?.missedHours},data-attestation-label=${att.kindLabel},data-semester=${att.semester}"
|
||||
data-action="attestation"
|
||||
th:title="${att.kindLabel} + ' — ' + ${att.semester}">
|
||||
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span>
|
||||
<span th:if="${row.attestations[att.id]?.missedHours != null}"
|
||||
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
|
||||
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
|
||||
@@ -139,7 +170,7 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
|
||||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 4}">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -173,6 +204,64 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог: добавление столбца аттестации -->
|
||||
<dialog id="modal-add-attestation" class="dlg">
|
||||
<div class="dlg-header">
|
||||
<h2 class="dlg-title">Новая аттестация</h2>
|
||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="dlg-body">
|
||||
<form class="form-stack" method="post" th:action="@{/admin/groups/{id}/attestations(id=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<div><label class="label" for="attestation-kind">Форма аттестации</label>
|
||||
<select class="input" id="attestation-kind" name="kind" required>
|
||||
<option value="intermediate">Промежуточная аттестация</option>
|
||||
<option value="pass">Зачёт</option>
|
||||
<option value="exam">Экзамен</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="attestation-semester">Семестр</label>
|
||||
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters"
|
||||
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required>
|
||||
<datalist id="attestation-semesters">
|
||||
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option>
|
||||
</datalist>
|
||||
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Добавить столбец</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог: результат аттестации студента -->
|
||||
<dialog id="modal-attestation" class="dlg">
|
||||
<div class="dlg-header">
|
||||
<h2 class="dlg-title" id="attestation-title">Результат аттестации</h2>
|
||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="dlg-body">
|
||||
<form class="form-stack" id="attestation-form" method="post"
|
||||
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="attestationId" id="attestation-id">
|
||||
<input type="hidden" name="studentId" id="attestation-student">
|
||||
<div><label class="label">Студент</label>
|
||||
<input class="input" id="attestation-who" readonly></div>
|
||||
<div><label class="label" for="attestation-value">Результат</label>
|
||||
<select class="input" id="attestation-value" name="value">
|
||||
<option value="">— не выставлено —</option>
|
||||
</select>
|
||||
<p class="hint">Оставьте пустым, чтобы убрать результат.</p>
|
||||
</div>
|
||||
<div id="attestation-hours-wrap"><label class="label" for="attestation-hours">Пропущено академических часов</label>
|
||||
<input class="input" id="attestation-hours" name="missedHours" type="number" min="0" step="1"
|
||||
inputmode="numeric" placeholder="например, 24">
|
||||
<p class="hint">Укажите для оценки «не атт», если известны пропуски по любой причине.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог: индивидуальное задание (защита) -->
|
||||
<!-- Диалог: назначение варианта -->
|
||||
<dialog id="modal-variant" class="dlg dlg-lg">
|
||||
@@ -347,7 +436,7 @@
|
||||
<div class="dlg-body" id="assign-modal-body"></div>
|
||||
</dialog>
|
||||
|
||||
<script th:src="@{/js/admin-group.js(v='39')}" defer></script>
|
||||
<script th:src="@{/js/admin-group.js(v='40')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
|
||||
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
|
||||
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css(v='37')}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css(v='38')}">
|
||||
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
|
||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
|
||||
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
|
||||
@@ -25,7 +25,7 @@
|
||||
<script th:src="@{/js/tablekit.js(v='38')}" defer></script>
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
|
||||
<script th:src="@{/js/theme-init.js(v='36')}"></script>
|
||||
<script th:src="@{/js/app.js(v='38')}" defer></script>
|
||||
<script th:src="@{/js/app.js(v='39')}" defer></script>
|
||||
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/form-feedback.js(v='39')}" defer></script>
|
||||
<script th:src="@{/js/modal-ajax.js(v='1')}" defer></script>
|
||||
|
||||
@@ -24,6 +24,30 @@
|
||||
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title mt-8">Аттестации</h2>
|
||||
<div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th><th>Пропущено</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="a : ${attestations}">
|
||||
<td th:text="${a.kindLabel}">—</td>
|
||||
<td th:text="${a.semester}">—</td>
|
||||
<td>
|
||||
<span class="badge"
|
||||
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
|
||||
th:text="${a.valueLabel}">—</span>
|
||||
</td>
|
||||
<td class="text-muted" th:text="${a.missedHours != null ? a.missedHours + ' ч.' : '—'}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div th:if="${#lists.isEmpty(attestations)}" class="card card-body text-muted mb-6">
|
||||
Аттестаций пока нет. Преподаватель добавит результаты после их выставления.
|
||||
</div>
|
||||
|
||||
<h2 class="section-title mt-8">Мои лабораторные</h2>
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-4">
|
||||
<div th:each="c : ${cards}" class="col"><div class="card h-100">
|
||||
|
||||
Reference in New Issue
Block a user