feat(web): профиль студента и ростер группы для преподавателя
This commit is contained in:
@@ -148,6 +148,7 @@ class AdminGroupController(
|
|||||||
@GetMapping("/groups/{id}")
|
@GetMapping("/groups/{id}")
|
||||||
fun groupDetail(
|
fun groupDetail(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
@@ -207,6 +208,7 @@ class AdminGroupController(
|
|||||||
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
|
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() })
|
||||||
return "admin/group"
|
return "admin/group"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ fun ApiClient.courseDetail(
|
|||||||
.body(CourseDetailDto::class.java)
|
.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(
|
fun ApiClient.updateCourse(
|
||||||
token: String,
|
token: String,
|
||||||
courseId: 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(),
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class CourseBootstrapResultDto(
|
data class CourseBootstrapResultDto(
|
||||||
val courseId: String = "",
|
val courseId: String = "",
|
||||||
@@ -663,3 +678,120 @@ data class GroupLabDataDto(
|
|||||||
val groupId: String = "",
|
val groupId: String = "",
|
||||||
val labs: List<LabDataDto> = emptyList(),
|
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.DateUtils
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
import ru.ysu.lms.web.api.courseDetail
|
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.listLabs
|
||||||
import ru.ysu.lms.web.api.myCourses
|
import ru.ysu.lms.web.api.myCourses
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
@@ -66,5 +67,20 @@ class TeacherController(
|
|||||||
return "teacher/course"
|
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
|
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="attached : ${attachedGroups}">
|
<tr th:each="attached : ${attachedGroups}">
|
||||||
<td>
|
<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>
|
th:text="${groupNames[attached.groupId] ?: attached.groupId}">—</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted" th:text="${attached.addedAt}">—</td>
|
<td class="text-muted" th:text="${attached.addedAt}">—</td>
|
||||||
|
|||||||
@@ -116,7 +116,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="row : ${studentGrades}">
|
<tr th:each="row : ${studentGrades}">
|
||||||
<td class="student-cell matrix-sticky">
|
<td class="student-cell matrix-sticky">
|
||||||
<div class="student-name" th:text="${row.fullName}">—</div>
|
<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">
|
<div class="student-no-line">
|
||||||
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
|
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
|
||||||
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
||||||
@@ -185,6 +188,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right actions-cell">
|
<td class="text-right actions-cell">
|
||||||
<div class="actions-col">
|
<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="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
|
<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}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit"
|
<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>
|
<h2 class="section-title">Группы курса</h2>
|
||||||
<div class="card card-flush">
|
<div class="card card-flush">
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
<tr th:each="group : ${groups}">
|
<tr th:each="group : ${groups}">
|
||||||
<td>
|
<td>
|
||||||
@@ -31,13 +37,19 @@
|
|||||||
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
|
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
|
||||||
class="font-mono"
|
class="font-mono"
|
||||||
th:text="${group.groupId}">—</a>
|
th:text="${group.groupId}">—</a>
|
||||||
<span th:unless="${currentUser.admin}" class="font-mono"
|
<a th:unless="${currentUser.admin}"
|
||||||
th:text="${group.groupId}">—</span>
|
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
|
||||||
|
class="font-mono"
|
||||||
|
th:text="${group.groupId}">—</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted" th:text="${group.addedAt}">—</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>
|
||||||
<tr th:if="${#lists.isEmpty(groups)}">
|
<tr th:if="${#lists.isEmpty(groups)}">
|
||||||
<td colspan="2" class="empty">К курсу не привязано ни одной группы.</td>
|
<td th:attr="colspan=${currentUser.admin ? 2 : 3}" class="empty">К курсу не привязано ни одной группы.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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