feat(web): профиль студента и ростер группы для преподавателя

This commit is contained in:
2026-10-27 17:45:13 +03:00
parent fa097518f8
commit 753cb5ba0a
15 changed files with 994 additions and 6 deletions

View File

@@ -148,6 +148,7 @@ class AdminGroupController(
@GetMapping("/groups/{id}")
fun groupDetail(
@PathVariable id: String,
@RequestParam(required = false) courseId: String?,
session: HttpSession,
model: Model,
): String {
@@ -207,6 +208,7 @@ class AdminGroupController(
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
),
)
model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() })
return "admin/group"
}

View File

@@ -57,6 +57,21 @@ fun ApiClient.courseDetail(
.body(CourseDetailDto::class.java)
}
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
fun ApiClient.courseGroupStudents(
token: String,
courseId: String,
groupId: String,
): CourseGroupRosterDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/groups/{groupId}/students", courseId, groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(CourseGroupRosterDto::class.java)
}
fun ApiClient.updateCourse(
token: String,
courseId: String,

View File

@@ -0,0 +1,36 @@
package ru.ysu.lms.web.api
import org.springframework.http.HttpHeaders
/** Агрегированный профиль студента в рамках одного курса. */
fun ApiClient.studentProfile(
token: String,
courseId: String,
studentId: String,
): StudentCourseProfileDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/students/{studentId}/profile", courseId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(StudentCourseProfileDto::class.java)
}
/** Скачивание конкретной попытки сдачи в рамках курса (преподаватель/админ). */
fun ApiClient.downloadCourseSubmission(
token: String,
courseId: String,
submissionId: String,
): DownloadDto {
val entity =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/submissions/{submissionId}/download", courseId, submissionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.toEntity(ByteArray::class.java)
}
return downloadOf(entity)
}

View File

@@ -318,6 +318,21 @@ data class CourseDetailDto(
val groups: List<CourseGroupDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupRosterDto(
val groupId: String = "",
val groupName: String = "",
val students: List<CourseGroupStudentDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupStudentDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val status: String = "ACTIVE",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseBootstrapResultDto(
val courseId: String = "",
@@ -663,3 +678,120 @@ data class GroupLabDataDto(
val groupId: String = "",
val labs: List<LabDataDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentCourseProfileDto(
val course: ProfileCourseDto = ProfileCourseDto(),
val student: ProfileStudentDto = ProfileStudentDto(),
val summary: ProfileSummaryDto = ProfileSummaryDto(),
val labs: List<ProfileLabDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileCourseDto(
val id: String = "",
val courseNo: Long = 0,
val title: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileStudentDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val groupId: String? = null,
val groupName: String? = null,
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileSummaryDto(
val labsTotal: Int = 0,
val submitted: Int = 0,
val graded: Int = 0,
val averageScore: Double? = null,
val defensesTotal: Int = 0,
val defensesReady: Int = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileLabDto(
val id: String = "",
val number: Int = 0,
val title: String = "",
val deadlineAt: String? = null,
val variant: ProfileVariantDto? = null,
val level: ProfileLevelDto? = null,
val attemptsCount: Int = 0,
val lastSubmissionAt: String? = null,
val grade: ProfileGradeDto? = null,
val comments: List<ProfileCommentDto> = emptyList(),
val defense: ProfileDefenseDto? = null,
val submissions: List<ProfileSubmissionDto> = emptyList(),
) {
/** Локальные для UI значения, заполняются контроллером. */
var deadlineMsk: String? = null
var lastSubmissionMsk: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileVariantDto(
val number: String = "",
val title: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileLevelDto(
val title: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileGradeDto(
/** Число или строка — в разных версиях progress-service тип оценки отличается. */
val score: Any? = null,
val published: Boolean = false,
val generalComment: String = "",
val author: ProfileAuthorDto? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileCommentDto(
val id: String = "",
val text: String = "",
val tone: String = "neutral",
val author: ProfileAuthorDto? = null,
val createdAt: String? = null,
) {
var createdAtMsk: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileDefenseDto(
val sessionId: String? = null,
val status: String? = null,
val readyReason: String? = null,
val code: String? = null,
val assignmentMd: String = "",
val secondsLeft: Long? = null,
) {
var statusLabel: String = ""
var assignmentHtml: String = ""
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileSubmissionDto(
val id: String = "",
val seq: Int = 0,
val createdAt: String? = null,
val files: List<String> = emptyList(),
val downloadUrl: String? = null,
) {
var createdAtMsk: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileAuthorDto(
val id: String = "",
val login: String = "",
)

View File

@@ -0,0 +1,163 @@
package ru.ysu.lms.web.profile
import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.StudentCourseProfileDto
import ru.ysu.lms.web.api.downloadCourseSubmission
import ru.ysu.lms.web.api.studentProfile
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@Controller
class StudentProfileController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
@GetMapping("/teacher/courses/{courseId}/students/{studentId}")
fun teacherProfile(
@PathVariable courseId: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
@GetMapping("/admin/courses/{courseId}/students/{studentId}")
fun adminProfile(
@PathVariable courseId: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
private fun renderProfile(
courseId: String,
studentId: String,
session: HttpSession,
model: Model,
): String {
val profile = api.studentProfile(token(session), courseId, studentId)
prepareProfile(profile)
val isAdmin = sessionUser(session)?.isAdmin == true
val courseUrl = if (isAdmin) "/admin/courses/$courseId" else "/teacher/courses/$courseId"
model.addAttribute("profile", profile)
model.addAttribute("isAdmin", isAdmin)
model.addAttribute("courseUrl", courseUrl)
model.addAttribute("backUrl", courseUrl)
model.addAttribute("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы")
model.addAttribute("backTitle", "Вернуться к курсу")
model.addAttribute(
"downloadBase",
if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download"
} else {
"/teacher/courses/{courseId}/submissions/{id}/download"
},
)
return "profile/student"
}
@GetMapping("/teacher/courses/{courseId}/submissions/{id}/download")
fun teacherDownload(
@PathVariable courseId: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
@GetMapping("/admin/courses/{courseId}/submissions/{id}/download")
fun adminDownload(
@PathVariable courseId: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
private fun downloadSubmission(
courseId: String,
id: String,
form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> {
val file = api.downloadCourseSubmission(token(session), courseId, id)
val filename = submissionFilename(form) ?: file.filename.ifBlank { "submission-$id.zip" }
val encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file.bytes)
}
private fun prepareProfile(profile: StudentCourseProfileDto) {
profile.labs.forEach { lab ->
lab.deadlineMsk = DateUtils.formatMsk(lab.deadlineAt)
lab.lastSubmissionMsk = DateUtils.formatMsk(lab.lastSubmissionAt)
lab.defense?.let { defense ->
defense.statusLabel = defenseStatusLabel(defense.status, defense.readyReason)
defense.assignmentHtml = if (defense.assignmentMd.isBlank()) "" else markdown.render(defense.assignmentMd)
}
lab.comments.forEach { comment -> comment.createdAtMsk = DateUtils.formatMsk(comment.createdAt) }
lab.submissions.forEach { submission -> submission.createdAtMsk = DateUtils.formatMsk(submission.createdAt) }
}
}
private fun defenseStatusLabel(
status: String?,
readyReason: String?,
): String =
when (status) {
null, "" -> "не назначена"
"waiting" -> "ожидает"
"started" -> "идёт"
"ready" -> if (readyReason == "timeout") "кончилось время" else "готова"
else -> status
}
/** Имя архива для скачивания: студент, ЛР и попытка, если они переданы в ссылке. */
private fun submissionFilename(form: ProfileSubmissionDownloadForm): String? {
val studentNo = form.studentNo?.takeIf { it.isNotBlank() } ?: form.no?.takeIf { it.isNotBlank() }
val parts =
listOfNotNull(
form.group?.takeIf { it.isNotBlank() },
studentNo,
form.name?.takeIf { it.isNotBlank() },
form.lab?.takeIf { it.isNotBlank() }?.let { "ЛР$it" },
form.attempt?.takeIf { it.isNotBlank() }?.let { "попытка$it" },
)
if (parts.isEmpty()) return null
val base =
parts
.joinToString("-")
.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
.trim()
return if (base.isBlank()) null else "$base.zip"
}
private fun sessionUser(session: HttpSession): SessionUser? = session.getAttribute(SessionKeys.USER) as? SessionUser
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}
data class ProfileSubmissionDownloadForm(
val studentNo: String? = null,
val no: String? = null,
val name: String? = null,
val lab: String? = null,
val attempt: String? = null,
val group: String? = null,
)

View File

@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.myCourses
import ru.ysu.lms.web.session.SessionKeys
@@ -66,5 +67,20 @@ class TeacherController(
return "teacher/course"
}
@GetMapping("/courses/{courseId}/groups/{groupId}")
fun group(
@PathVariable courseId: String,
@PathVariable groupId: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val roster = api.courseGroupStudents(token, courseId, groupId)
model.addAttribute("courseId", courseId)
model.addAttribute("groupId", groupId)
model.addAttribute("roster", roster)
return "teacher/group"
}
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
}

View File

@@ -34,7 +34,7 @@
<tbody>
<tr th:each="attached : ${attachedGroups}">
<td>
<a th:href="@{/admin/groups/{id}(id=${attached.groupId})}"
<a th:href="@{/admin/groups/{id}(id=${attached.groupId}, courseId=${course.id})}"
th:text="${groupNames[attached.groupId] ?: attached.groupId}">—</a>
</td>
<td class="text-muted" th:text="${attached.addedAt}">—</td>

View File

@@ -116,7 +116,10 @@
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<div class="student-name" th:text="${row.fullName}">—</div>
<a class="student-name" th:if="${profileCourseId != null}"
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
th:text="${row.fullName}">—</a>
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
<div class="student-no-line">
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
@@ -185,6 +188,8 @@
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"

View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${profile.student.fullName} + ' — LMS'">Профиль студента</title></head>
<body>
<div id="content">
<a th:href="${backUrl}" class="nav-link" th:text="${backLabel}" th:title="${backTitle}">← Мои курсы</a>
<div class="page-head mb-2">
<div>
<p class="kicker">Профиль студента курса</p>
<h1 class="page-title" th:text="${profile.student.fullName}">Иванов Иван Иванович</h1>
<p class="meta">
<span class="font-mono" th:text="${profile.student.studentNo}">123456</span>
<span class="nav-sep">·</span>
<span th:text="${profile.student.groupName ?: 'без группы'}">ПИ-21БО</span>
<span class="nav-sep">·</span>
<span class="badge"
th:classappend="${profile.student.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${profile.student.status == 'ACTIVE' ? 'активен' : profile.student.status}">активен</span>
<span class="nav-sep">·</span>
<span th:if="${profile.student.enrollmentYear != null}"
th:text="|набор ${profile.student.enrollmentYear}|">набор 2025</span>
<span th:if="${profile.student.enrollmentYear == null}" class="text-muted">год набора не указан</span>
</p>
</div>
</div>
<div class="card card-body mb-4">
<div class="row g-3">
<div class="col-md-4">
<p class="stat-title">Студент</p>
<p class="font-medium mb-1" th:text="${profile.student.fullName}">—</p>
<p class="text-sm text-muted font-mono m-0" th:text="${profile.student.studentNo}">—</p>
</div>
<div class="col-md-4">
<p class="stat-title">Группа</p>
<p class="font-medium mb-1" th:text="${profile.student.groupName ?: 'без группы'}">—</p>
<p class="text-sm text-muted m-0"
th:text="${profile.student.enrollmentYear != null ? 'Набор ' + profile.student.enrollmentYear : 'Год набора не указан'}">—</p>
</div>
<div class="col-md-4">
<p class="stat-title">Курс</p>
<p class="font-medium mb-1">
<a th:href="${courseUrl}" th:text="|№${profile.course.courseNo}. ${profile.course.title}|">—</a>
</p>
<p class="text-sm text-muted m-0" th:if="${profile.student.status != 'ACTIVE'}">
Статус: <span th:text="${profile.student.status}">—</span>
</p>
<p class="text-sm text-muted m-0" th:if="${profile.student.status == 'ACTIVE'}">Студент обучается</p>
</div>
</div>
</div>
<h2 class="section-title">Сводка по курсу</h2>
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 row-cols-xl-6 g-3">
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Лабораторных</p>
<p class="stat-value" th:text="${profile.summary.labsTotal}">0</p>
</div>
</div>
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Сдано</p>
<p class="stat-value" th:text="${profile.summary.submitted}">0</p>
</div>
</div>
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Оценено</p>
<p class="stat-value" th:text="${profile.summary.graded}">0</p>
</div>
</div>
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Средний балл</p>
<p class="stat-value" th:text="${profile.summary.averageScore ?: '—'}">—</p>
</div>
</div>
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Защит всего</p>
<p class="stat-value" th:text="${profile.summary.defensesTotal}">0</p>
</div>
</div>
<div class="col">
<div class="card card-body h-100">
<p class="stat-title">Защит готово</p>
<p class="stat-value" th:text="${profile.summary.defensesReady}">0</p>
</div>
</div>
</div>
<h2 class="section-title mt-8">Лабораторные курса</h2>
<div th:if="${#lists.isEmpty(profile.labs)}" class="card card-body text-muted">В курсе пока нет лабораторных.</div>
<div th:each="lab : ${profile.labs}" class="card mb-4">
<div class="card-body">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 class="section-title m-0" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h3>
<p class="meta mt-1">
<span th:if="${lab.variant != null}">
Вариант <b class="font-mono" th:text="${lab.variant.number}">1</b><span
th:if="${!#strings.isEmpty(lab.variant.title)}" th:text="| · ${lab.variant.title}|"></span>
</span>
<span th:if="${lab.variant == null}" class="text-muted">вариант не выдан</span>
<span th:if="${lab.level != null}" th:text="| · уровень: ${lab.level.title}|"></span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="badge badge-warn" th:if="${lab.attemptsCount == 0}">не сдано</span>
<span class="badge" th:if="${lab.attemptsCount > 0}"
th:text="|попыток: ${lab.attemptsCount}|">попыток: 0</span>
<span class="badge" th:if="${lab.grade == null}">без оценки</span>
<span class="badge badge-ok" th:if="${lab.grade != null and lab.grade.published}">оценка опубликована</span>
<span class="badge badge-warn" th:if="${lab.grade != null and !lab.grade.published}">оценка скрыта</span>
</div>
</div>
<p class="meta mt-2">
<span th:if="${lab.deadlineMsk != null}">Срок сдачи: <b th:text="${lab.deadlineMsk}">—</b> МСК</span>
<span th:if="${lab.deadlineMsk == null}" class="text-muted">Срок сдачи не указан</span>
<span th:if="${lab.lastSubmissionMsk != null}" th:text="| · последняя сдача: ${lab.lastSubmissionMsk} МСК|"></span>
</p>
<div class="row g-4 mt-2">
<div class="col-lg-6">
<h4 class="group-title">Оценка</h4>
<p class="mb-2" th:if="${lab.grade != null}">
<span class="score-big" th:text="${lab.grade.score}">4.5</span>
<span class="text-sm text-muted" th:if="${lab.grade.author != null}"
th:text="|Автор: ${lab.grade.author.login}|"></span>
</p>
<p class="text-muted text-sm" th:if="${lab.grade == null}">Оценка ещё не выставлена.</p>
<div class="general-comment" th:if="${lab.grade != null and !#strings.isEmpty(lab.grade.generalComment)}">
<h4 class="group-title">Общий комментарий</h4>
<p class="pre-wrap mb-0" th:text="${lab.grade.generalComment}">—</p>
</div>
</div>
<div class="col-lg-6">
<h4 class="group-title">Защита</h4>
<p class="text-muted text-sm" th:if="${lab.defense == null}">Защита не назначена.</p>
<div th:if="${lab.defense != null}">
<p class="mb-2">
<span class="badge"
th:classappend="${lab.defense.status == 'ready' ? (lab.defense.readyReason == 'timeout' ? 'badge-warn' : 'badge-ok') : (lab.defense.status == 'started' ? 'badge-warn' : '')}"
th:text="${lab.defense.statusLabel}">—</span>
<span class="text-sm text-muted" th:if="${lab.defense.code != null}"
th:text="|Код: ${lab.defense.code}|"></span>
<span class="text-sm text-muted" th:if="${lab.defense.secondsLeft != null}"
th:text="|Осталось: ${lab.defense.secondsLeft} с|"></span>
</p>
<details th:if="${!#strings.isEmpty(lab.defense.assignmentHtml)}">
<summary class="text-sm">Индивидуальное задание</summary>
<div class="md-body mt-2" th:utext="${lab.defense.assignmentHtml}"></div>
</details>
</div>
</div>
</div>
<h4 class="group-title mt-4">Комментарии преподавателей</h4>
<p class="text-muted text-sm mb-0" th:if="${#lists.isEmpty(lab.comments)}">Комментариев нет.</p>
<ul class="review-list" th:if="${!#lists.isEmpty(lab.comments)}">
<li th:each="comment : ${lab.comments}">
<span class="badge badge-xs"
th:classappend="${comment.tone == 'positive' ? 'badge-ok' : (comment.tone == 'negative' ? 'tone-negative-text' : '')}"
th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный</span>
<span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span>
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
</li>
</ul>
<h4 class="group-title mt-4">Попытки сдачи</h4>
<p class="text-muted text-sm mb-0" th:if="${#lists.isEmpty(lab.submissions)}">Сдач по этой лабораторной нет.</p>
<div class="card card-flush" th:if="${!#lists.isEmpty(lab.submissions)}">
<table>
<thead>
<tr><th>Попытка</th><th>Время (МСК)</th><th>Файлы</th><th class="text-right">Скачать</th></tr>
</thead>
<tbody>
<tr th:each="submission : ${lab.submissions}">
<td th:text="${submission.seq}">1</td>
<td class="text-muted" th:text="${submission.createdAtMsk ?: submission.createdAt}">—</td>
<td class="font-mono text-sm">
<span th:if="${#lists.isEmpty(submission.files)}" class="text-muted">—</span>
<span th:if="${!#lists.isEmpty(submission.files)}"
th:text="${#strings.listJoin(submission.files, ', ')}">main.c</span>
</td>
<td class="text-right">
<a class="btn btn-secondary btn-sm"
th:href="@{${downloadBase}(courseId=${profile.course.id}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -23,7 +23,13 @@
<h2 class="section-title">Группы курса</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Группа</th><th>Добавлена</th></tr></thead>
<thead>
<tr>
<th>Группа</th>
<th>Добавлена</th>
<th th:unless="${currentUser.admin}" data-nosort></th>
</tr>
</thead>
<tbody>
<tr th:each="group : ${groups}">
<td>
@@ -31,13 +37,19 @@
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
class="font-mono"
th:text="${group.groupId}">—</a>
<span th:unless="${currentUser.admin}" class="font-mono"
th:text="${group.groupId}">—</span>
<a th:unless="${currentUser.admin}"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
class="font-mono"
th:text="${group.groupId}">—</a>
</td>
<td class="text-muted" th:text="${group.addedAt}">—</td>
<td th:unless="${currentUser.admin}" class="text-center">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="2" class="empty">К курсу не привязано ни одной группы.</td>
<td th:attr="colspan=${currentUser.admin ? 2 : 3}" class="empty">К курсу не привязано ни одной группы.</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${(#strings.isEmpty(roster.groupName) ? groupId : roster.groupName)} + ' — LMS'">Группа</title></head>
<body>
<div id="content">
<a th:href="@{/teacher/courses/{id}(id=${courseId})}" class="nav-link">← К курсу</a>
<div class="page-head mb-2">
<div>
<p class="kicker">Группа курса</p>
<h1 class="page-title"
th:text="${#strings.isEmpty(roster.groupName) ? groupId : roster.groupName}">Группа</h1>
<p class="meta">
<span th:if="${!#strings.isEmpty(roster.groupName)}" class="font-mono"
th:text="${roster.groupId}">—</span>
<span th:if="${!#strings.isEmpty(roster.groupName)}" class="nav-sep">·</span>
<span th:text="|Студентов: ${#lists.size(roster.students)}|">Студентов: 0</span>
</p>
</div>
</div>
<div class="card card-flush mt-4">
<table>
<thead>
<tr>
<th>Студент</th>
<th>Номер</th>
<th>Статус</th>
<th class="text-right" data-nosort>Профиль</th>
</tr>
</thead>
<tbody>
<tr th:each="student : ${roster.students}">
<td>
<a class="font-medium"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}"
th:text="${student.fullName}">—</a>
</td>
<td class="font-mono" th:text="${student.studentNo}">—</td>
<td>
<span class="badge"
th:classappend="${student.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${student.status == 'ACTIVE' ? 'активен' : (student.status == 'EXPELLED' ? 'отчислен' : (student.status == 'ACADEMIC_LEAVE' ? 'академ' : (student.status == 'GRADUATED' ? 'выпущен' : 'архив')))}">—</span>
</td>
<td class="text-right">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(roster.students)}">
<td colspan="4" class="empty">В группе нет студентов.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.web.api
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import tools.jackson.databind.ObjectMapper
class CourseGroupRosterDtoTest {
@Test
fun `deserializes course group roster contract`() {
val json =
"""
{
"groupId": "g1",
"groupName": "ПИ-21БО",
"students": [
{"id": "s1", "studentNo": "123456", "fullName": "Иванов Иван", "status": "ACTIVE"},
{"id": "s2", "studentNo": "123457", "fullName": "Петров Пётр", "status": "EXPELLED"}
],
"unknownField": "ignored"
}
""".trimIndent()
val roster = ObjectMapper().readValue(json, CourseGroupRosterDto::class.java)
assertThat(roster.groupId).isEqualTo("g1")
assertThat(roster.groupName).isEqualTo("ПИ-21БО")
assertThat(roster.students).hasSize(2)
assertThat(roster.students[0].fullName).isEqualTo("Иванов Иван")
assertThat(roster.students[1].status).isEqualTo("EXPELLED")
}
}

View File

@@ -0,0 +1,56 @@
package ru.ysu.lms.web.profile
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import ru.ysu.lms.web.api.ProfileGradeDto
import ru.ysu.lms.web.api.StudentCourseProfileDto
import tools.jackson.databind.ObjectMapper
class ProfileDtoJsonTest {
@Test
fun `deserializes profile contract`() {
val json =
"""
{
"course": {"id": "c1", "courseNo": 1, "title": "Курс C"},
"student": {"id": "s1", "studentNo": "1", "fullName": "Иванов", "groupId": "g1",
"groupName": "ПИ-21", "status": "ACTIVE", "enrollmentYear": 2025},
"summary": {"labsTotal": 1, "submitted": 1, "graded": 1, "averageScore": 4.5,
"defensesTotal": 1, "defensesReady": 0},
"labs": [{
"id": "l1", "number": 1, "title": "ЛР", "deadlineAt": "2026-10-01T20:59:00Z",
"variant": {"number": "3", "title": "В-3"},
"level": {"title": "Базовый"},
"attemptsCount": 1,
"lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {"score": "4.5", "published": true, "generalComment": "Ок",
"author": {"id": "t1", "login": "petrov"}},
"comments": [{"id": "c1", "text": "Комментарий", "tone": "neutral",
"author": {"id": "t1", "login": "petrov"}, "createdAt": "2026-09-29T09:30:00Z"}],
"defense": {"sessionId": "d1", "status": "ready", "readyReason": "timeout",
"code": null, "assignmentMd": "Задание", "secondsLeft": null},
"submissions": [{"id": "sub1", "seq": 1, "createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c"], "downloadUrl": "/download"}]
}]
}
""".trimIndent()
val profile = ObjectMapper().readValue(json, StudentCourseProfileDto::class.java)
assertThat(profile.student.fullName).isEqualTo("Иванов")
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
assertThat(profile.labs[0].grade?.score).isEqualTo("4.5")
assertThat(profile.labs[0].submissions[0].files).containsExactly("main.c")
}
@Test
fun `coerces numeric grade score to string`() {
val grade =
ObjectMapper().readValue(
"""{"score": 4.5, "published": true, "generalComment": ""}""",
ProfileGradeDto::class.java,
)
assertThat(grade.score.toString()).isEqualTo("4.5")
}
}

View File

@@ -0,0 +1,173 @@
package ru.ysu.lms.web.profile
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.api.ProfileAuthorDto
import ru.ysu.lms.web.api.ProfileCommentDto
import ru.ysu.lms.web.api.ProfileCourseDto
import ru.ysu.lms.web.api.ProfileDefenseDto
import ru.ysu.lms.web.api.ProfileGradeDto
import ru.ysu.lms.web.api.ProfileLabDto
import ru.ysu.lms.web.api.ProfileLevelDto
import ru.ysu.lms.web.api.ProfileStudentDto
import ru.ysu.lms.web.api.ProfileSubmissionDto
import ru.ysu.lms.web.api.ProfileSummaryDto
import ru.ysu.lms.web.api.ProfileVariantDto
import ru.ysu.lms.web.api.StudentCourseProfileDto
import java.util.Locale
class StudentProfileTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
@Test
fun `renders student profile with labs, comments and downloads`() {
val html = render(isAdmin = true)
assertThat(html).contains("Иванов Иван Иванович")
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("ЛР 1. Введение в C")
assertThat(html).contains("Вариант")
assertThat(html).contains("попыток: 2")
assertThat(html).contains("4.5")
assertThat(html).contains("Хорошо")
assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время")
assertThat(html).contains("main.c")
assertThat(html).contains("/admin/courses/c1/submissions/s1/download")
assertThat(html).contains("studentNo=123456")
assertThat(html).contains("attempt=2")
assertThat(html).contains("← Все курсы")
}
@Test
fun `renders teacher back link and teacher download route`() {
val html = render(isAdmin = false)
assertThat(html).contains("← Мои курсы")
assertThat(html).contains("/teacher/courses/c1/submissions/s1/download")
}
private fun render(isAdmin: Boolean): 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("profile", sampleProfile())
context.setVariable("isAdmin", isAdmin)
context.setVariable("courseUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1")
context.setVariable("backUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1")
context.setVariable("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы")
context.setVariable("backTitle", "Вернуться к курсу")
context.setVariable(
"downloadBase",
if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download"
} else {
"/teacher/courses/{courseId}/submissions/{id}/download"
},
)
return engine.process("profile/student", context)
}
private fun sampleProfile(): StudentCourseProfileDto =
StudentCourseProfileDto(
course = ProfileCourseDto(id = "c1", courseNo = 1, title = "Курс C"),
student =
ProfileStudentDto(
id = "st1",
studentNo = "123456",
fullName = "Иванов Иван Иванович",
groupId = "g1",
groupName = "ПИ-21БО",
status = "ACTIVE",
enrollmentYear = 2025,
),
summary =
ProfileSummaryDto(
labsTotal = 1,
submitted = 1,
graded = 1,
averageScore = 4.5,
defensesTotal = 1,
defensesReady = 1,
),
labs = listOf(sampleLab()),
)
private fun sampleLab(): ProfileLabDto =
ProfileLabDto(
id = "l1",
number = 1,
title = "Введение в C",
deadlineAt = "2026-10-01T20:59:00Z",
variant = ProfileVariantDto(number = "3", title = "Вариант 3"),
level = ProfileLevelDto(title = "Базовый"),
attemptsCount = 2,
lastSubmissionAt = "2026-09-28T18:12:00Z",
grade = sampleGrade(),
comments = listOf(sampleComment()),
defense = sampleDefense(),
submissions = listOf(sampleSubmission()),
).apply {
deadlineMsk = "01.10.2026 23:59"
lastSubmissionMsk = "28.09.2026 21:12"
}
private fun sampleGrade(): ProfileGradeDto =
ProfileGradeDto(
score = "4.5",
published = true,
generalComment = "Хорошо",
author = ProfileAuthorDto(id = "t1", login = "petrov"),
)
private fun sampleComment(): ProfileCommentDto =
ProfileCommentDto(
id = "c1",
text = "Разделите функцию на части",
tone = "neutral",
author = ProfileAuthorDto(id = "t1", login = "petrov"),
createdAt = "2026-09-29T09:30:00Z",
).apply { createdAtMsk = "29.09.2026 12:30" }
private fun sampleDefense(): ProfileDefenseDto =
ProfileDefenseDto(
sessionId = "d1",
status = "ready",
readyReason = "timeout",
assignmentMd = "**Задание**",
).apply {
statusLabel = "кончилось время"
assignmentHtml = "<p><strong>Задание</strong></p>"
}
private fun sampleSubmission(): ProfileSubmissionDto =
ProfileSubmissionDto(
id = "s1",
seq = 2,
createdAt = "2026-09-28T18:12:00Z",
files = listOf("main.c", "Makefile"),
).apply { createdAtMsk = "28.09.2026 21:12" }
}

View File

@@ -0,0 +1,83 @@
package ru.ysu.lms.web.teacher
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.api.CourseGroupRosterDto
import ru.ysu.lms.web.api.CourseGroupStudentDto
import java.util.Locale
class TeacherGroupTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
@Test
fun `renders group roster with course and profile links`() {
val html = render(groupName = "ПИ-21БО")
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("Иванов Иван")
assertThat(html).contains("123456")
assertThat(html).contains("/teacher/courses/c1")
assertThat(html).contains("/teacher/courses/c1/students/s1")
assertThat(html).contains("активен")
}
@Test
fun `falls back to group id when name is missing`() {
val html = render(groupName = "")
assertThat(html).contains("g1")
assertThat(html).contains("В группе нет студентов.")
}
private fun render(groupName: 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("courseId", "c1")
context.setVariable("groupId", "g1")
context.setVariable(
"roster",
CourseGroupRosterDto(
groupId = "g1",
groupName = groupName,
students =
if (groupName.isEmpty()) {
emptyList()
} else {
listOf(
CourseGroupStudentDto(
id = "s1",
studentNo = "123456",
fullName = "Иванов Иван",
status = "ACTIVE",
),
)
},
),
)
return engine.process("teacher/group", context)
}
}