diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 9718fc6..16c5f63 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -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" } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt index 8a54935..28fce70 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt @@ -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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt new file mode 100644 index 0000000..d6a2643 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt @@ -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) +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 73dd998..bc07df1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -318,6 +318,21 @@ data class CourseDetailDto( val groups: List = emptyList(), ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseGroupRosterDto( + val groupId: String = "", + val groupName: String = "", + val students: List = 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 = emptyList(), ) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class StudentCourseProfileDto( + val course: ProfileCourseDto = ProfileCourseDto(), + val student: ProfileStudentDto = ProfileStudentDto(), + val summary: ProfileSummaryDto = ProfileSummaryDto(), + val labs: List = 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 = emptyList(), + val defense: ProfileDefenseDto? = null, + val submissions: List = 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 = emptyList(), + val downloadUrl: String? = null, +) { + var createdAtMsk: String? = null +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ProfileAuthorDto( + val id: String = "", + val login: String = "", +) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt b/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt new file mode 100644 index 0000000..38919d7 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt @@ -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 = 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 = downloadSubmission(courseId, id, form, session) + + private fun downloadSubmission( + courseId: String, + id: String, + form: ProfileSubmissionDownloadForm, + session: HttpSession, + ): ResponseEntity { + 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, +) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt index fdaf479..39e0cba 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt @@ -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 } diff --git a/web/src/main/resources/templates/admin/course.html b/web/src/main/resources/templates/admin/course.html index 924166e..3681204 100644 --- a/web/src/main/resources/templates/admin/course.html +++ b/web/src/main/resources/templates/admin/course.html @@ -34,7 +34,7 @@ - — — diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 56d703d..a6315a6 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -116,7 +116,10 @@ -
—
+ — +
—
+ Профиль