feat(web): профиль студента и ростер группы для преподавателя
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt
Normal file
36
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt
Normal 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)
|
||||
}
|
||||
@@ -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 = "",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
206
web/src/main/resources/templates/profile/student.html
Normal file
206
web/src/main/resources/templates/profile/student.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
58
web/src/main/resources/templates/teacher/group.html
Normal file
58
web/src/main/resources/templates/teacher/group.html
Normal 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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user