diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt deleted file mode 100644 index 882fff2..0000000 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ /dev/null @@ -1,1336 +0,0 @@ -package ru.ysu.lms.web.admin - -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.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.multipart.MultipartFile -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import ru.ysu.lms.web.api.ApiClient -import ru.ysu.lms.web.api.ApiException -import ru.ysu.lms.web.api.AssignVariantPayload -import ru.ysu.lms.web.api.CreateStudentRequestDto -import ru.ysu.lms.web.api.DistributeRequestPayload -import ru.ysu.lms.web.api.GradeSetPayload -import ru.ysu.lms.web.api.LabCreatePayload -import ru.ysu.lms.web.api.LabSummaryDto -import ru.ysu.lms.web.api.LabUpdatePayload -import ru.ysu.lms.web.api.LevelAssignPayload -import ru.ysu.lms.web.api.LevelPayload -import ru.ysu.lms.web.api.LevelSavePayload -import ru.ysu.lms.web.api.StudentRowDto -import ru.ysu.lms.web.api.VariantSavePayload -import ru.ysu.lms.web.api.addLevel -import ru.ysu.lms.web.api.assignDefense -import ru.ysu.lms.web.api.assignVariant -import ru.ysu.lms.web.api.createGroup -import ru.ysu.lms.web.api.createLab -import ru.ysu.lms.web.api.createStudent -import ru.ysu.lms.web.api.credentials -import ru.ysu.lms.web.api.deleteLab -import ru.ysu.lms.web.api.deleteLevel -import ru.ysu.lms.web.api.deleteVariant -import ru.ysu.lms.web.api.distribute -import ru.ysu.lms.web.api.downloadSubmissionAsAdmin -import ru.ysu.lms.web.api.enrollStudent -import ru.ysu.lms.web.api.getLabAssignments -import ru.ysu.lms.web.api.getLevels -import ru.ysu.lms.web.api.groupCandidates -import ru.ysu.lms.web.api.groupDetail -import ru.ysu.lms.web.api.groupPublications -import ru.ysu.lms.web.api.importGroup -import ru.ysu.lms.web.api.importLab -import ru.ysu.lms.web.api.issueDefenseCode -import ru.ysu.lms.web.api.labDefenses -import ru.ysu.lms.web.api.labDetail -import ru.ysu.lms.web.api.labSubmissions -import ru.ysu.lms.web.api.listGrades -import ru.ysu.lms.web.api.listGroups -import ru.ysu.lms.web.api.listLabs -import ru.ysu.lms.web.api.publishGrades -import ru.ysu.lms.web.api.renameLevel -import ru.ysu.lms.web.api.resetDefense -import ru.ysu.lms.web.api.resetPassword -import ru.ysu.lms.web.api.saveVariant -import ru.ysu.lms.web.api.setGrade -import ru.ysu.lms.web.api.setLevel -import ru.ysu.lms.web.api.toggleLabStatus -import ru.ysu.lms.web.api.unenrollStudent -import ru.ysu.lms.web.api.updateLab -import ru.ysu.lms.web.session.SessionKeys -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream - -@Controller -@RequestMapping("/admin") -class AdminController( - private val api: ApiClient, - private val markdown: ru.ysu.lms.markdown.MarkdownRenderer, - private val mapper: tools.jackson.databind.ObjectMapper, -) { - @GetMapping - fun dashboard( - session: HttpSession, - model: Model, - ): String { - val t = token(session) - val groups = api.listGroups(t) - val labs = api.listLabs(t) - val grades = api.listGrades(t) - model.addAttribute("groupCount", groups.size) - model.addAttribute("studentCount", groups.sumOf { it.studentCount }) - model.addAttribute("groupCount", groups.size) - model.addAttribute("labCount", labs.size) - val gradedByStudent = grades.groupBy { it.studentId } - val averages = - gradedByStudent.values.mapNotNull { rows -> - rows - .mapNotNull { g -> g.score.toBigDecimalOrNull() } - .takeIf { it.isNotEmpty() } - ?.let { nums -> nums.map { it.toDouble() }.average() } - } - model.addAttribute("avgGrade", if (averages.isEmpty()) null else averages.average()) - model.addAttribute("publishedLabs", labs.count { it.status == "published" }) - model.addAttribute("labs", labs) - model.addAttribute("groups", groups) - return "admin/dashboard" - } - - @GetMapping("/groups") - fun groups( - session: HttpSession, - model: Model, - ): String { - model.addAttribute("groups", api.listGroups(token(session))) - return "admin/groups" - } - - @PostMapping("/groups") - fun createGroup( - @RequestParam name: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.createGroup(token(session), name.trim()) - redirect.addFlashAttribute("notice", "Группа создана") - "redirect:/admin/groups" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups" - } - - @PostMapping("/groups/import") - fun importGroup( - @RequestParam("file") file: MultipartFile, - @RequestParam(name = "name", required = false) name: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String { - if (file.isEmpty) { - redirect.addFlashAttribute("error", "Выберите файл со списком группы") - return "redirect:/admin/groups" - } - return try { - val result = - api.importGroup( - token(session), - name, - file.originalFilename ?: "group.xlsx", - file.bytes, - ) - redirect.addFlashAttribute("importResult", result) - "redirect:/admin/groups/${result.groupId}" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups" - } - } - - @GetMapping("/groups/{id}") - fun groupDetail( - @PathVariable id: String, - session: HttpSession, - model: Model, - ): String { - val t = token(session) - val group = api.groupDetail(t, id) - val labs = api.listLabs(t).filter { it.status == "published" } - val grades = api.listGrades(t) - val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } - val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } - val publishedLabs = java.util.HashSet(api.groupPublications(t, id)) - val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } } - val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } } - val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } } - - val rows = - group.students.map { s -> - val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() } - val cells = - labs.map { lab -> - val va = assignmentsBy.getValue(lab)[s.id] - val sub = submissionsBy.getValue(lab)[s.id] - val def = defensesBy.getValue(lab)[s.id] - GroupLabCell( - labId = lab.id, - labNumber = lab.number, - labTitle = lab.title, - variantNumber = va?.variantNumber, - variantManual = va?.manual == true, - score = gradeBy["${s.id}|${lab.id}"], - published = publishedLabs.contains(lab.id), - submissionSeq = sub?.seq, - submissionId = sub?.id, - defenseStatus = def?.status, - defenseCode = def?.code, - defenseSecondsLeft = def?.secondsLeft, - defenseAssigned = def?.assignmentMd?.isNotBlank() == true, - ) - } - GroupStudentRow( - studentId = s.id, - fullName = s.fullName, - studentNo = s.studentNo, - basis = s.basis, - mustChangePassword = s.mustChangePassword, - cells = cells, - variantsSummary = - cells - .filter { it.variantNumber != null } - .joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" }, - commentsJson = - mapper.writeValueAsString( - comments.map { cs -> - cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } - }, - ), - ) - } - - val defenseRows = - labs - .flatMap { lab -> - val byStudent = api.labDefenses(t, lab.id).associateBy { it.studentId } - group.students.mapNotNull { s -> - val d = byStudent[s.id] ?: return@mapNotNull null - GroupDefenseRow( - sessionId = d.id, - studentId = s.id, - fullName = s.fullName, - studentNo = s.studentNo, - labId = lab.id, - labNumber = lab.number, - status = d.status, - code = d.code?.takeIf { d.status == "waiting" }, - assignmentMd = d.assignmentMd, - secondsLeft = d.secondsLeft, - ) - } - }.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber })) - - model.addAttribute("group", group) - model.addAttribute("labs", labs) - model.addAttribute("candidates", runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())) - model.addAttribute("studentGrades", rows) - model.addAttribute("defenseRows", defenseRows) - model.addAttribute("publishedLabIds", publishedLabs) - return "admin/group" - } - - @PostMapping("/groups/{id}/grades") - fun setGrade( - @PathVariable id: String, - @RequestParam studentId: String, - @RequestParam labId: String, - @RequestParam score: String, - @RequestParam(required = false) commentsJson: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val comments = parseComments(commentsJson ?: "[]") - api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments)) - redirect.addFlashAttribute("notice", "Оценка сохранена") - "redirect:/admin/groups/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$id" - } - - @PostMapping("/groups/{id}/students/{studentId}/reset-password") - fun resetPassword( - @PathVariable id: String, - @PathVariable studentId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = api.resetPassword(token(session), studentId) - redirect.addFlashAttribute("resetPassword", result.password) - redirect.addFlashAttribute("resetFor", studentId) - "redirect:/admin/groups/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$id" - } - - @GetMapping("/groups/{id}/credentials") - fun credentials( - @PathVariable id: String, - @RequestParam(defaultValue = "xlsx") format: String, - session: HttpSession, - ): ResponseEntity { - val download = api.credentials(token(session), id, format) - val encoded = URLEncoder.encode(download.filename, StandardCharsets.UTF_8).replace("+", "%20") - return ResponseEntity - .ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") - .contentType(MediaType.parseMediaType(download.contentType)) - .body(download.bytes) - } - - @GetMapping("/labs") - fun labs( - session: HttpSession, - model: Model, - ): String { - model.addAttribute("labs", api.listLabs(token(session))) - return "admin/labs" - } - - @PostMapping("/labs/import") - fun importLab( - @RequestParam("file") file: MultipartFile, - session: HttpSession, - redirect: RedirectAttributes, - ): String { - if (file.isEmpty) { - redirect.addFlashAttribute("error", "Выберите файл") - return "redirect:/admin/labs" - } - return try { - val result = - api.importLab( - token(session), - file.originalFilename ?: "lab.zip", - file.bytes, - ) - redirect.addFlashAttribute( - "notice", - "Лабораторная ${result.number}: «${result.title}» импортирована (${result.variantCount} вариантов)", - ) - "redirect:/admin/labs" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs" - } - } - - @PostMapping("/labs/{id}/toggle-status") - fun toggleStatus( - @PathVariable id: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.toggleLabStatus(token(session), id) - redirect.addFlashAttribute("notice", "Статус лабораторной изменён") - "redirect:/admin/labs/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$id" - } - - @PostMapping("/labs/{id}/delete") - fun deleteLab( - @PathVariable id: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.deleteLab(token(session), id) - redirect.addFlashAttribute("notice", "Лабораторная удалена") - "redirect:/admin/labs" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs" - } - - @GetMapping("/labs/{id}") - fun labDetail( - @PathVariable id: String, - session: HttpSession, - model: Model, - ): String { - val t = token(session) - val lab = api.labDetail(t, id) - val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } - val assignments = api.getLabAssignments(t, id) - val assignedCount = assignments.size - val levelByStudent = api.getLevels(t, id).associateBy { it.studentId } - val assignmentByStudent = assignments.associateBy { it.studentId } - val variantUsage = java.util.HashMap() - assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } - val allVariants = lab.levels.flatMap { it.variants } - val totalVariants = allVariants.size - val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } - val defaultLevel = lab.levels.firstOrNull() - - val assignmentGroups = - api.listGroups(t).map { g -> - val detail = api.groupDetail(t, g.id) - AdminLabGroup( - groupName = g.name, - students = - detail.students.map { s -> - val levelState = levelByStudent[s.id] - val level = lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: defaultLevel - val va = assignmentByStudent[s.id] - AdminLabStudent( - studentId = s.id, - fullName = s.fullName, - studentNo = s.studentNo, - levelId = level?.id ?: "", - levelTitle = level?.title ?: "—", - levelChanged = levelState?.manual == true, - variantId = va?.variantId, - variantNumber = va?.variantNumber, - source = if (va?.manual == true) "вручную" else "авто", - submissionId = submissionsByStudent[s.id]?.id, - submissionSeq = submissionsByStudent[s.id]?.seq, - submissionFiles = submissionsByStudent[s.id]?.files ?: emptyList(), - ) - }, - ) - } - - model.addAttribute("lab", lab) - model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) - model.addAttribute( - "deadlineMsk", - ru.ysu.lms.web.DateUtils - .formatMsk(lab.deadlineAt), - ) - model.addAttribute( - "defenseMsk", - ru.ysu.lms.web.DateUtils - .formatMsk(lab.defenseAt), - ) - model.addAttribute( - "deadlineLocal", - ru.ysu.lms.web.DateUtils - .localInput(lab.deadlineAt), - ) - model.addAttribute( - "defenseLocal", - ru.ysu.lms.web.DateUtils - .localInput(lab.defenseAt), - ) - model.addAttribute("assignedCount", assignedCount) - model.addAttribute("submissionCount", submissionsByStudent.size) - model.addAttribute("assignmentGroups", assignmentGroups) - model.addAttribute("groups", api.listGroups(t)) - model.addAttribute("variantUsage", variantUsage) - model.addAttribute("totalVariants", totalVariants) - model.addAttribute("occupiedVariants", occupiedVariants) - model.addAttribute("freeVariants", totalVariants - occupiedVariants) - return "admin/lab" - } - - @PostMapping("/labs/{labId}/defense/assign") - fun assignDefense( - @PathVariable labId: String, - @RequestParam studentId: String, - @RequestParam assignmentMd: String, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.assignDefense(token(session), studentId, labId, assignmentMd) - redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(groupId, labId) - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) - } - - /** Назначение/правка индивидуального задания со страницы группы. */ - @PostMapping("/defenses/save") - fun saveDefense( - @RequestParam studentId: String, - @RequestParam labId: String, - @RequestParam assignmentMd: String, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.assignDefense(token(session), studentId, labId, assignmentMd) - redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(groupId, labId) - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) - } - - @PostMapping("/labs/{labId}/defense/import") - fun importDefenses( - @PathVariable labId: String, - @RequestParam("file") file: MultipartFile, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = importDefenses(token(session), labId, file, groupId, redirect) - - /** Импорт CSV со страницы группы: лабораторная выбирается в форме или берётся из строки. */ - @PostMapping("/defenses/import") - fun importDefensesGeneric( - @RequestParam(required = false) labId: String?, - @RequestParam("file") file: MultipartFile, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = importDefenses(token(session), labId, file, groupId, redirect) - - private fun importDefenses( - t: String, - labId: String?, - file: MultipartFile, - groupId: String?, - redirect: RedirectAttributes, - ): String = - try { - val studentByNo = mutableMapOf() - for (g in api.listGroups(t)) { - api.groupDetail(t, g.id).students.forEach { studentByNo[it.studentNo] = it.id } - } - val labByNumber = api.listLabs(t).associateBy { it.number.toString() } - val rows = parseCsv(file.bytes) - var count = 0 - var skipped = 0 - for ((idx, rawCols) in rows.withIndex()) { - val row = resolveDefenseRow(rawCols, idx, labId, studentByNo, labByNumber) - if (row == null) { - skipped++ - } else { - api.assignDefense(t, row.studentId, row.labId, row.assignmentMd.replace("\\n", "\n")) - count++ - } - } - val tail = if (skipped > 0) ", пропущено: $skipped" else "" - redirect.addFlashAttribute("notice", "Импортировано заданий: $count$tail") - redirectToGroup(groupId, labId) - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) - } - - private data class DefenseRow( - val studentId: String, - val labId: String, - val assignmentMd: String, - ) - - /** Разбирает строку CSV с заданием на защиту; null — строку нужно пропустить. */ - private fun resolveDefenseRow( - rawCols: List, - index: Int, - defaultLabId: String?, - studentByNo: Map, - labByNumber: Map, - ): DefenseRow? { - val cells = rawCols.map { it.trim() } - if (cells.all { it.isBlank() }) return null - if (index == 0 && looksLikeHeader(cells)) return null - var sid: String? = null - var lid: String? = defaultLabId?.takeIf { it.isNotBlank() } - var md: String? = null - when { - cells.size >= 3 -> { - sid = studentByNo[cells[0]] - lid = labByNumber[cells[1]]?.id ?: lid - md = cells.drop(2).joinToString("; ") - } - cells.size == 2 -> { - sid = studentByNo[cells[0]] - md = cells[1] - } - } - if (sid == null || lid.isNullOrBlank() || md.isNullOrBlank()) return null - return DefenseRow(sid, lid, md) - } - - private fun looksLikeHeader(cells: List): Boolean { - val joined = cells.joinToString(" ").lowercase() - return joined.contains("студент") || - joined.contains("student") || - joined.contains("фио") || - joined.contains("задан") || - joined.contains("assignment") - } - - /** Простой CSV: разделитель ; , или таб; поддержка кавычек и экранирования "" */ - private fun parseCsv(bytes: ByteArray): List> { - val text = String(bytes, Charsets.UTF_8).removePrefix("\uFEFF") - val lines = text.lines().filter { it.isNotBlank() } - val sample = lines.firstOrNull() ?: return emptyList() - val delimiter = - when { - sample.contains('\t') -> '\t' - sample.contains(';') -> ';' - else -> ',' - } - return lines.map { splitCsvLine(it, delimiter) } - } - - private fun splitCsvLine( - line: String, - delimiter: Char, - ): List { - val out = mutableListOf() - val sb = StringBuilder() - var inQuotes = false - var i = 0 - while (i < line.length) { - val c = line[i] - when { - inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> { - sb.append('"') - i++ - } - inQuotes && c == '"' -> inQuotes = false - inQuotes -> sb.append(c) - c == '"' -> inQuotes = true - c == delimiter -> { - out.add(sb.toString()) - sb.setLength(0) - } - else -> sb.append(c) - } - i++ - } - out.add(sb.toString()) - return out - } - - @PostMapping("/defenses/{id}/issue-code") - fun issueDefenseCode( - @PathVariable id: String, - @RequestParam labId: String, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val code = api.issueDefenseCode(token(session), id) - redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") - redirectToGroup(groupId, labId) - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) - } - - @PostMapping("/defenses/{id}/reset") - fun resetDefenseSession( - @PathVariable id: String, - @RequestParam labId: String, - @RequestParam(required = false) groupId: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.resetDefense(token(session), id) - redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") - redirectToGroup(groupId, labId) - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) - } - - private fun redirectToGroup( - groupId: String?, - labId: String?, - ): String = - when { - !groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId" - !labId.isNullOrBlank() -> "redirect:/admin/labs/$labId" - else -> "redirect:/admin/groups" - } - - @PostMapping("/groups/{gid}/set-level") - fun setStudentLevel( - @PathVariable gid: String, - @RequestParam studentId: String, - @RequestParam labId: String, - @RequestParam labLevelId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) - redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически") - "redirect:/admin/groups/$gid" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" - } - - @PostMapping("/groups/{gid}/pin-variant") - fun pinStudentVariant( - @PathVariable gid: String, - @RequestParam studentId: String, - @RequestParam labId: String, - @RequestParam variantId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId)) - redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}") - "redirect:/admin/groups/$gid" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" - } - - @PostMapping("/groups/{gid}/distribute") - fun distributeForGroup( - @PathVariable gid: String, - @RequestParam labId: String, - @RequestParam(defaultValue = "false") force: Boolean, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = - api.distribute( - token(session), - DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)), - ) - redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") - "redirect:/admin/groups/$gid" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" - } - - /** Раздать варианты по всем опубликованным ЛР для одной группы. */ - @PostMapping("/groups/{gid}/distribute-all") - fun distributeAllForGroup( - @PathVariable gid: String, - @RequestParam(defaultValue = "false") force: Boolean, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val t = token(session) - val labs = api.listLabs(t).filter { it.status == "published" } - if (labs.isEmpty()) { - redirect.addFlashAttribute("error", "Нет опубликованных лабораторных") - return "redirect:/admin/groups/$gid" - } - val result = - api.distribute( - t, - DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)), - ) - redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}") - "redirect:/admin/groups/$gid" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" - } - - /** Массовая установка уровня выделенным студентам. */ - @PostMapping("/labs/{labId}/batch-level") - fun batchLevel( - @PathVariable labId: String, - @RequestParam("studentIds") studentIds: List, - @RequestParam labLevelId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val t = token(session) - studentIds.filter { it.isNotBlank() }.forEach { sid -> - api.setLevel(t, LevelAssignPayload(sid, labId, labLevelId)) - } - redirect.addFlashAttribute("notice", "Уровень обновлён у ${studentIds.size} студент(ов)") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - /** Массовое закрепление конкретного варианта выделенным студентам. */ - @PostMapping("/labs/{labId}/batch-variant") - fun batchVariant( - @PathVariable labId: String, - @RequestParam("studentIds") studentIds: List, - @RequestParam variantId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val t = token(session) - studentIds.filter { it.isNotBlank() }.forEach { sid -> - api.assignVariant(t, AssignVariantPayload(sid, labId, variantId)) - } - redirect.addFlashAttribute("notice", "Вариант закреплён у ${studentIds.size} студент(ов)") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - @PostMapping("/labs/{id}/distribute") - fun distribute( - @PathVariable id: String, - @RequestParam(defaultValue = "false") force: Boolean, - @RequestParam(required = false) groupIds: List?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = - api.distribute( - token(session), - DistributeRequestPayload( - labId = id, - force = force, - groupIds = groupIds.orEmpty().filter { it.isNotBlank() }, - ), - ) - redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") - "redirect:/admin/labs/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$id" - } - - @GetMapping("/groups/{id}/submissions.zip") - fun downloadGroupSubmissions( - @PathVariable id: String, - @RequestParam(required = false) labId: String?, - session: HttpSession, - ): ResponseEntity { - val t = token(session) - val group = api.groupDetail(t, id) - val studentById = group.students.associateBy { it.id } - val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } - val bytes = buildGroupArchive(t, group.name, studentById, labs) - return zipAttachment("solutions-${group.name}.zip", bytes) - } - - private fun buildGroupArchive( - token: String, - groupName: String, - studentById: Map, - labs: List, - ): ByteArray { - val out = ByteArrayOutputStream() - ZipOutputStream(out).use { zos -> - zos.putNextEntry(ZipEntry("ОПИСАНИЕ.txt").apply { time = 0 }) - zos.write("Архив сдач группы $groupName (последние попытки)\n".toByteArray(Charsets.UTF_8)) - zos.closeEntry() - for (lab in labs) { - for (sub in api.labSubmissions(token, lab.id)) { - val student = studentById[sub.studentId] ?: continue - val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes - appendSubmissionArchive(zos, lab, student, bytes) - } - } - } - return out.toByteArray() - } - - private fun appendSubmissionArchive( - zos: ZipOutputStream, - lab: LabSummaryDto, - student: StudentRowDto, - submissionZip: ByteArray, - ) { - ZipInputStream(ByteArrayInputStream(submissionZip)).use { zin -> - var entry = zin.nextEntry - while (entry != null) { - if (!entry.isDirectory) { - val safeName = student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_") - zos.putNextEntry(ZipEntry("ЛР${lab.number}/${student.studentNo}_$safeName/${entry.name}").apply { time = 0 }) - zin.copyTo(zos) - zos.closeEntry() - } - entry = zin.nextEntry - } - } - } - - private fun zipAttachment( - name: String, - bytes: ByteArray, - ): ResponseEntity { - val encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) - return ResponseEntity - .ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(bytes) - } - - @PostMapping("/groups/{id}/publish") - fun publishGrades( - @PathVariable id: String, - @RequestParam labId: String, - @RequestParam published: Boolean, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.publishGrades(token(session), labId, id, published) - redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы") - "redirect:/admin/groups/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$id" - } - - @GetMapping("/submissions/{id}/download") - fun downloadSubmissionAsAdmin( - @PathVariable id: String, - @RequestParam(required = false) group: String?, - @RequestParam(required = false) no: String?, - @RequestParam(required = false) name: String?, - @RequestParam(required = false) attempt: String?, - session: HttpSession, - ): ResponseEntity { - val file = api.downloadSubmissionAsAdmin(token(session), id) - val filename = submissionFilename(group, no, name, attempt) ?: file.filename - val encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) - return ResponseEntity - .ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(file.bytes) - } - - /** Человекочитаемое имя архива сдачи: группа-номер студенческого-ФИО-попыткаN.zip */ - private fun submissionFilename( - group: String?, - no: String?, - name: String?, - attempt: String?, - ): String? { - val parts = - listOfNotNull( - group?.takeIf { it.isNotBlank() }, - no?.takeIf { it.isNotBlank() }, - name?.takeIf { it.isNotBlank() }, - 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" - } - - @PostMapping("/labs/{labId}/assign") - fun assignVariant( - @PathVariable labId: String, - @RequestParam studentId: String, - @RequestParam variantId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId)) - redirect.addFlashAttribute("notice", "Назначен вариант ${result.variantNumber}") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - @PostMapping("/labs/create") - fun createLab( - @RequestParam number: Int, - @RequestParam title: String, - @RequestParam(required = false) deadlineAt: String?, - @RequestParam(required = false) defenseAt: String?, - @RequestParam pointsMax: String, - @RequestParam status: String, - @RequestParam descriptionMd: String, - @RequestParam levels: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - // levels: строки "код;название" по одной на строку - val levelList = - levels - .lines() - .map { it.trim() } - .filter { it.isNotBlank() } - .mapIndexed { idx, line -> - val parts = line.split(";", limit = 2) - LevelPayload( - code = parts[0].trim(), - title = parts.getOrNull(1)?.trim().takeUnless { it.isNullOrBlank() } ?: parts[0].trim(), - position = idx + 1, - ) - } - val created = - api.createLab( - token(session), - LabCreatePayload( - number = number, - title = title.trim(), - deadlineAt = - ru.ysu.lms.web.DateUtils - .toUtcIso(deadlineAt), - defenseAt = - ru.ysu.lms.web.DateUtils - .toUtcIso(defenseAt), - pointsMax = java.math.BigDecimal(pointsMax), - status = status, - descriptionMd = descriptionMd, - levels = levelList, - ), - ) - redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана") - "redirect:/admin/labs/${created.id}" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs" - } - - @PostMapping("/labs/{labId}/levels/save") - fun saveLevel( - @PathVariable labId: String, - @RequestParam(required = false) levelId: String?, - @RequestParam code: String, - @RequestParam title: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - if (levelId.isNullOrBlank()) { - api.addLevel(token(session), labId, LevelSavePayload(code, title)) - redirect.addFlashAttribute("notice", "Уровень добавлен") - } else { - api.renameLevel(token(session), labId, levelId, title) - redirect.addFlashAttribute("notice", "Уровень обновлён") - } - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - @PostMapping("/labs/{labId}/levels/{levelId}/delete") - fun deleteLevel( - @PathVariable labId: String, - @PathVariable levelId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.deleteLevel(token(session), labId, levelId) - redirect.addFlashAttribute("notice", "Уровень удалён") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - @PostMapping("/groups/{groupId}/students/create") - fun createStudent( - @PathVariable groupId: String, - @RequestParam studentNo: String, - @RequestParam lastName: String, - @RequestParam(required = false) firstName: String?, - @RequestParam(required = false) middleName: String?, - @RequestParam(required = false) basis: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - val result = - api.createStudent( - token(session), - groupId, - CreateStudentRequestDto( - studentNo = studentNo.trim(), - lastName = lastName.trim(), - firstName = firstName.orEmpty().trim(), - middleName = middleName.orEmpty().trim(), - basis = basis.orEmpty().trim(), - ), - ) - redirect.addFlashAttribute("createdStudent", result) - "redirect:/admin/groups/$groupId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$groupId" - } - - @PostMapping("/groups/{groupId}/students/enroll") - fun enrollStudent( - @PathVariable groupId: String, - @RequestParam studentId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.enrollStudent(token(session), groupId, studentId) - redirect.addFlashAttribute("notice", "Студент зачислен в группу") - "redirect:/admin/groups/$groupId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$groupId" - } - - @PostMapping("/groups/{groupId}/students/{studentId}/unenroll") - fun unenrollStudent( - @PathVariable groupId: String, - @PathVariable studentId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.unenrollStudent(token(session), groupId, studentId) - redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)") - "redirect:/admin/groups/$groupId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$groupId" - } - - @GetMapping("/labs/{id}/edit") - fun editLabForm( - @PathVariable id: String, - session: HttpSession, - model: Model, - ): String { - val lab = api.labDetail(token(session), id) - // datetime-local требует формат yyyy-MM-dd'T'HH:mm - model.addAttribute("lab", lab) - model.addAttribute( - "deadlineLocal", - ru.ysu.lms.web.DateUtils - .localInput(lab.deadlineAt), - ) - model.addAttribute( - "defenseLocal", - ru.ysu.lms.web.DateUtils - .localInput(lab.defenseAt), - ) - return "admin/lab-edit" - } - - /** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */ - @PostMapping("/labs/{id}/edit") - fun editLab( - @PathVariable id: String, - @RequestParam title: String, - @RequestParam(required = false) deadlineAt: String?, - @RequestParam(required = false) defenseAt: String?, - @RequestParam pointsMax: String, - @RequestParam status: String, - @RequestParam descriptionMd: String, - @RequestParam(defaultValue = "save") action: String, - @RequestParam(required = false) levelIds: List?, - @RequestParam(required = false) levelTitles: List?, - @RequestParam(required = false) variantIds: List?, - @RequestParam(required = false) variantLevelIds: List?, - @RequestParam(required = false) variantNumbers: List?, - @RequestParam(required = false) variantTitles: List?, - @RequestParam(required = false) variantStatements: List?, - @RequestParam(required = false) newLevelCode: String?, - @RequestParam(required = false) newLevelTitle: String?, - @RequestParam(required = false) addLevelId: String?, - @RequestParam(required = false) addNumber: String?, - @RequestParam(required = false) addTitle: String?, - @RequestParam(required = false) addStatementMd: String?, - session: HttpSession, - redirect: RedirectAttributes, - ): String { - val t = token(session) - return try { - when { - action.startsWith("delete-level-") -> { - api.deleteLevel(t, id, action.removePrefix("delete-level-")) - redirect.addFlashAttribute("notice", "Уровень удалён") - return "redirect:/admin/labs/$id/edit" - } - action.startsWith("delete-variant-") -> { - api.deleteVariant(t, id, action.removePrefix("delete-variant-")) - redirect.addFlashAttribute("notice", "Вариант удалён") - return "redirect:/admin/labs/$id/edit" - } - } - - api.updateLab( - t, - id, - LabUpdatePayload( - title, - ru.ysu.lms.web.DateUtils - .toUtcIso(deadlineAt), - ru.ysu.lms.web.DateUtils - .toUtcIso(defenseAt), - pointsMax, - status, - descriptionMd, - ), - ) - levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) -> - if (levelId.isNotBlank() && newTitle.isNotBlank()) { - api.renameLevel(t, id, levelId, newTitle.trim()) - } - } - variantIds.orEmpty().forEachIndexed { idx, variantId -> - val number = - variantNumbers - .orEmpty() - .getOrNull(idx) - .orEmpty() - .trim() - if (variantId.isNotBlank() && number.isNotBlank()) { - api.saveVariant( - t, - id, - variantId, - VariantSavePayload( - variantLevelIds.orEmpty().getOrNull(idx).orEmpty(), - number, - variantTitles - .orEmpty() - .getOrNull(idx) - .orEmpty() - .trim(), - variantStatements.orEmpty().getOrNull(idx).orEmpty(), - ), - ) - } - } - when { - action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> { - api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim())) - redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен") - } - action == "add-variant" && !addLevelId.isNullOrBlank() && !addNumber.isNullOrBlank() -> { - api.saveVariant( - t, - id, - null, - VariantSavePayload( - addLevelId, - addNumber.trim(), - addTitle.orEmpty().trim(), - addStatementMd.orEmpty(), - ), - ) - redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен") - } - else -> redirect.addFlashAttribute("notice", "Все изменения сохранены") - } - "redirect:/admin/labs/$id/edit" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$id/edit" - } - } - - @PostMapping("/labs/{labId}/variants/save") - fun saveVariant( - @PathVariable labId: String, - @RequestParam(required = false) variantId: String?, - @RequestParam labLevelId: String, - @RequestParam number: String, - @RequestParam title: String, - @RequestParam statementMd: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.saveVariant(token(session), labId, variantId, VariantSavePayload(labLevelId, number, title, statementMd)) - redirect.addFlashAttribute("notice", "Вариант сохранён") - "redirect:/admin/labs/$labId/edit" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId/edit" - } - - @PostMapping("/labs/{labId}/variants/{variantId}/delete") - fun deleteVariant( - @PathVariable labId: String, - @PathVariable variantId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.deleteVariant(token(session), labId, variantId) - redirect.addFlashAttribute("notice", "Вариант удалён") - "redirect:/admin/labs/$labId/edit" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId/edit" - } - - @PostMapping("/labs/{labId}/set-level") - fun setLevel( - @PathVariable labId: String, - @RequestParam studentId: String, - @RequestParam labLevelId: String, - session: HttpSession, - redirect: RedirectAttributes, - ): String = - try { - api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) - redirect.addFlashAttribute("notice", "Уровень студента установлен") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" - } - - private fun parseComments(json: String): List { - if (json.isBlank() || json == "[]") return emptyList() - return try { - val node = mapper.readTree(json) - buildList { - node.forEach { el -> - val text = el.path("text").asString("").trim() - if (text.isNotEmpty()) { - add( - ru.ysu.lms.web.api - .CommentPayload(text, el.path("tone").asString("neutral")), - ) - } - } - } - } catch (_: Exception) { - emptyList() - } - } - - private fun token(session: HttpSession): String = - session.getAttribute(SessionKeys.TOKEN) as? String - ?: throw ApiException(401, "Сессия истекла, войдите заново") -} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt new file mode 100644 index 0000000..60883d2 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt @@ -0,0 +1,116 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.assignDefense +import ru.ysu.lms.web.api.issueDefenseCode +import ru.ysu.lms.web.api.resetDefense +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin") +class AdminDefenseController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @PostMapping("/labs/{labId}/defense/assign") + fun assignDefense( + @PathVariable labId: String, + @RequestParam studentId: String, + @RequestParam assignmentMd: String, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.assignDefense(token(session), studentId, labId, assignmentMd) + redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + /** Назначение/правка индивидуального задания со страницы группы. */ + @PostMapping("/defenses/save") + fun saveDefense( + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam assignmentMd: String, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.assignDefense(token(session), studentId, labId, assignmentMd) + redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + @PostMapping("/labs/{labId}/defense/import") + fun importDefenses( + @PathVariable labId: String, + @RequestParam("file") file: MultipartFile, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = importDefenses(token(session), labId, file, groupId, redirect) + + /** Импорт CSV со страницы группы: лабораторная выбирается в форме или берётся из строки. */ + + @PostMapping("/defenses/import") + fun importDefensesGeneric( + @RequestParam(required = false) labId: String?, + @RequestParam("file") file: MultipartFile, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = importDefenses(token(session), labId, file, groupId, redirect) + + @PostMapping("/defenses/{id}/issue-code") + fun issueDefenseCode( + @PathVariable id: String, + @RequestParam labId: String, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val code = api.issueDefenseCode(token(session), id) + redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + @PostMapping("/defenses/{id}/reset") + fun resetDefenseSession( + @PathVariable id: String, + @RequestParam labId: String, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.resetDefense(token(session), id) + redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt new file mode 100644 index 0000000..487c552 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt @@ -0,0 +1,179 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.AssignVariantPayload +import ru.ysu.lms.web.api.DistributeRequestPayload +import ru.ysu.lms.web.api.LevelAssignPayload +import ru.ysu.lms.web.api.assignVariant +import ru.ysu.lms.web.api.distribute +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.setLevel +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin") +class AdminDistributeController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @PostMapping("/groups/{gid}/set-level") + fun setStudentLevel( + @PathVariable gid: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) + redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + @PostMapping("/groups/{gid}/pin-variant") + fun pinStudentVariant( + @PathVariable gid: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId)) + redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + @PostMapping("/groups/{gid}/distribute") + fun distributeForGroup( + @PathVariable gid: String, + @RequestParam labId: String, + @RequestParam(defaultValue = "false") force: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = + api.distribute( + token(session), + DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)), + ) + redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + /** Раздать варианты по всем опубликованным ЛР для одной группы. */ + @PostMapping("/groups/{gid}/distribute-all") + fun distributeAllForGroup( + @PathVariable gid: String, + @RequestParam(defaultValue = "false") force: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val t = token(session) + val labs = api.listLabs(t).filter { it.status == "published" } + if (labs.isEmpty()) { + redirect.addFlashAttribute("error", "Нет опубликованных лабораторных") + return "redirect:/admin/groups/$gid" + } + val result = + api.distribute( + t, + DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)), + ) + redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + /** Массовая установка уровня выделенным студентам. */ + @PostMapping("/labs/{labId}/batch-level") + fun batchLevel( + @PathVariable labId: String, + @RequestParam("studentIds") studentIds: List, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val t = token(session) + studentIds.filter { it.isNotBlank() }.forEach { sid -> + api.setLevel(t, LevelAssignPayload(sid, labId, labLevelId)) + } + redirect.addFlashAttribute("notice", "Уровень обновлён у ${studentIds.size} студент(ов)") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + /** Массовое закрепление конкретного варианта выделенным студентам. */ + @PostMapping("/labs/{labId}/batch-variant") + fun batchVariant( + @PathVariable labId: String, + @RequestParam("studentIds") studentIds: List, + @RequestParam variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val t = token(session) + studentIds.filter { it.isNotBlank() }.forEach { sid -> + api.assignVariant(t, AssignVariantPayload(sid, labId, variantId)) + } + redirect.addFlashAttribute("notice", "Вариант закреплён у ${studentIds.size} студент(ов)") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/{id}/distribute") + fun distribute( + @PathVariable id: String, + @RequestParam(defaultValue = "false") force: Boolean, + @RequestParam(required = false) groupIds: List?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = + api.distribute( + token(session), + DistributeRequestPayload( + labId = id, + force = force, + groupIds = groupIds.orEmpty().filter { it.isNotBlank() }, + ), + ) + redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") + "redirect:/admin/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id" + } +} 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 new file mode 100644 index 0000000..843361d --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -0,0 +1,284 @@ +package ru.ysu.lms.web.admin + +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.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.GradeSetPayload +import ru.ysu.lms.web.api.createGroup +import ru.ysu.lms.web.api.downloadSubmissionAsAdmin +import ru.ysu.lms.web.api.getLabAssignments +import ru.ysu.lms.web.api.groupCandidates +import ru.ysu.lms.web.api.groupDetail +import ru.ysu.lms.web.api.groupPublications +import ru.ysu.lms.web.api.importGroup +import ru.ysu.lms.web.api.labDefenses +import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listGrades +import ru.ysu.lms.web.api.listGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.publishGrades +import ru.ysu.lms.web.api.setGrade +import tools.jackson.databind.ObjectMapper +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +@Controller +@RequestMapping("/admin") +class AdminGroupController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping + fun dashboard( + session: HttpSession, + model: Model, + ): String { + val t = token(session) + val groups = api.listGroups(t) + val labs = api.listLabs(t) + val grades = api.listGrades(t) + model.addAttribute("groupCount", groups.size) + model.addAttribute("studentCount", groups.sumOf { it.studentCount }) + model.addAttribute("groupCount", groups.size) + model.addAttribute("labCount", labs.size) + val gradedByStudent = grades.groupBy { it.studentId } + val averages = + gradedByStudent.values.mapNotNull { rows -> + rows + .mapNotNull { g -> g.score.toBigDecimalOrNull() } + .takeIf { it.isNotEmpty() } + ?.let { nums -> nums.map { it.toDouble() }.average() } + } + model.addAttribute("avgGrade", if (averages.isEmpty()) null else averages.average()) + model.addAttribute("publishedLabs", labs.count { it.status == "published" }) + model.addAttribute("labs", labs) + model.addAttribute("groups", groups) + return "admin/dashboard" + } + + @GetMapping("/groups") + fun groups( + session: HttpSession, + model: Model, + ): String { + model.addAttribute("groups", api.listGroups(token(session))) + return "admin/groups" + } + + @PostMapping("/groups") + fun createGroup( + @RequestParam name: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.createGroup(token(session), name.trim()) + redirect.addFlashAttribute("notice", "Группа создана") + "redirect:/admin/groups" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups" + } + + @PostMapping("/groups/import") + fun importGroup( + @RequestParam("file") file: MultipartFile, + @RequestParam(name = "name", required = false) name: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + if (file.isEmpty) { + redirect.addFlashAttribute("error", "Выберите файл со списком группы") + return "redirect:/admin/groups" + } + return try { + val result = + api.importGroup( + token(session), + name, + file.originalFilename ?: "group.xlsx", + file.bytes, + ) + redirect.addFlashAttribute("importResult", result) + "redirect:/admin/groups/${result.groupId}" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups" + } + } + + @GetMapping("/groups/{id}") + fun groupDetail( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val t = token(session) + val group = api.groupDetail(t, id) + val labs = api.listLabs(t).filter { it.status == "published" } + val grades = api.listGrades(t) + val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } + val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } + val publishedLabs = java.util.HashSet(api.groupPublications(t, id)) + val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } } + val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } } + val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } } + + val rows = + group.students.map { s -> + val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() } + val cells = + labs.map { lab -> + val va = assignmentsBy.getValue(lab)[s.id] + val sub = submissionsBy.getValue(lab)[s.id] + val def = defensesBy.getValue(lab)[s.id] + GroupLabCell( + labId = lab.id, + labNumber = lab.number, + labTitle = lab.title, + variantNumber = va?.variantNumber, + variantManual = va?.manual == true, + score = gradeBy["${s.id}|${lab.id}"], + published = publishedLabs.contains(lab.id), + submissionSeq = sub?.seq, + submissionId = sub?.id, + defenseStatus = def?.status, + defenseCode = def?.code, + defenseSecondsLeft = def?.secondsLeft, + defenseAssigned = def?.assignmentMd?.isNotBlank() == true, + ) + } + GroupStudentRow( + studentId = s.id, + fullName = s.fullName, + studentNo = s.studentNo, + basis = s.basis, + mustChangePassword = s.mustChangePassword, + cells = cells, + variantsSummary = + cells + .filter { it.variantNumber != null } + .joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" }, + commentsJson = + mapper.writeValueAsString( + comments.map { cs -> + cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } + }, + ), + ) + } + + val defenseRows = + labs + .flatMap { lab -> + val byStudent = api.labDefenses(t, lab.id).associateBy { it.studentId } + group.students.mapNotNull { s -> + val d = byStudent[s.id] ?: return@mapNotNull null + GroupDefenseRow( + sessionId = d.id, + studentId = s.id, + fullName = s.fullName, + studentNo = s.studentNo, + labId = lab.id, + labNumber = lab.number, + status = d.status, + code = d.code?.takeIf { d.status == "waiting" }, + assignmentMd = d.assignmentMd, + secondsLeft = d.secondsLeft, + ) + } + }.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber })) + + model.addAttribute("group", group) + model.addAttribute("labs", labs) + model.addAttribute("candidates", runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())) + model.addAttribute("studentGrades", rows) + model.addAttribute("defenseRows", defenseRows) + model.addAttribute("publishedLabIds", publishedLabs) + return "admin/group" + } + + @PostMapping("/groups/{id}/grades") + fun setGrade( + @PathVariable id: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam score: String, + @RequestParam(required = false) commentsJson: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val comments = parseComments(commentsJson ?: "[]") + api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments)) + redirect.addFlashAttribute("notice", "Оценка сохранена") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + @GetMapping("/groups/{id}/submissions.zip") + fun downloadGroupSubmissions( + @PathVariable id: String, + @RequestParam(required = false) labId: String?, + session: HttpSession, + ): ResponseEntity { + val t = token(session) + val group = api.groupDetail(t, id) + val studentById = group.students.associateBy { it.id } + val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } + val bytes = buildGroupArchive(t, group.name, studentById, labs) + return zipAttachment("solutions-${group.name}.zip", bytes) + } + + @PostMapping("/groups/{id}/publish") + fun publishGrades( + @PathVariable id: String, + @RequestParam labId: String, + @RequestParam published: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.publishGrades(token(session), labId, id, published) + redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + @GetMapping("/submissions/{id}/download") + fun downloadSubmissionAsAdmin( + @PathVariable id: String, + @RequestParam(required = false) group: String?, + @RequestParam(required = false) no: String?, + @RequestParam(required = false) name: String?, + @RequestParam(required = false) attempt: String?, + session: HttpSession, + ): ResponseEntity { + val file = api.downloadSubmissionAsAdmin(token(session), id) + val filename = submissionFilename(group, no, name, attempt) ?: file.filename + val encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) + return ResponseEntity + .ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(file.bytes) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt new file mode 100644 index 0000000..834e7aa --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt @@ -0,0 +1,496 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.AssignVariantPayload +import ru.ysu.lms.web.api.LabCreatePayload +import ru.ysu.lms.web.api.LabUpdatePayload +import ru.ysu.lms.web.api.LevelAssignPayload +import ru.ysu.lms.web.api.LevelPayload +import ru.ysu.lms.web.api.LevelSavePayload +import ru.ysu.lms.web.api.VariantSavePayload +import ru.ysu.lms.web.api.addLevel +import ru.ysu.lms.web.api.assignVariant +import ru.ysu.lms.web.api.createLab +import ru.ysu.lms.web.api.deleteLab +import ru.ysu.lms.web.api.deleteLevel +import ru.ysu.lms.web.api.deleteVariant +import ru.ysu.lms.web.api.getLabAssignments +import ru.ysu.lms.web.api.getLevels +import ru.ysu.lms.web.api.groupDetail +import ru.ysu.lms.web.api.importLab +import ru.ysu.lms.web.api.labDetail +import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.renameLevel +import ru.ysu.lms.web.api.saveVariant +import ru.ysu.lms.web.api.setLevel +import ru.ysu.lms.web.api.toggleLabStatus +import ru.ysu.lms.web.api.updateLab +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin") +class AdminLabController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping("/labs") + fun labs( + session: HttpSession, + model: Model, + ): String { + model.addAttribute("labs", api.listLabs(token(session))) + return "admin/labs" + } + + @PostMapping("/labs/import") + fun importLab( + @RequestParam("file") file: MultipartFile, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + if (file.isEmpty) { + redirect.addFlashAttribute("error", "Выберите файл") + return "redirect:/admin/labs" + } + return try { + val result = + api.importLab( + token(session), + file.originalFilename ?: "lab.zip", + file.bytes, + ) + redirect.addFlashAttribute( + "notice", + "Лабораторная ${result.number}: «${result.title}» импортирована (${result.variantCount} вариантов)", + ) + "redirect:/admin/labs" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + } + + @PostMapping("/labs/{id}/toggle-status") + fun toggleStatus( + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.toggleLabStatus(token(session), id) + redirect.addFlashAttribute("notice", "Статус лабораторной изменён") + "redirect:/admin/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id" + } + + @PostMapping("/labs/{id}/delete") + fun deleteLab( + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.deleteLab(token(session), id) + redirect.addFlashAttribute("notice", "Лабораторная удалена") + "redirect:/admin/labs" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + + @GetMapping("/labs/{id}") + fun labDetail( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val t = token(session) + val lab = api.labDetail(t, id) + val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } + val assignments = api.getLabAssignments(t, id) + val assignedCount = assignments.size + val levelByStudent = api.getLevels(t, id).associateBy { it.studentId } + val assignmentByStudent = assignments.associateBy { it.studentId } + val variantUsage = java.util.HashMap() + assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } + val allVariants = lab.levels.flatMap { it.variants } + val totalVariants = allVariants.size + val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } + val defaultLevel = lab.levels.firstOrNull() + + val assignmentGroups = + api.listGroups(t).map { g -> + val detail = api.groupDetail(t, g.id) + AdminLabGroup( + groupName = g.name, + students = + detail.students.map { s -> + val levelState = levelByStudent[s.id] + val level = lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: defaultLevel + val va = assignmentByStudent[s.id] + AdminLabStudent( + studentId = s.id, + fullName = s.fullName, + studentNo = s.studentNo, + levelId = level?.id ?: "", + levelTitle = level?.title ?: "—", + levelChanged = levelState?.manual == true, + variantId = va?.variantId, + variantNumber = va?.variantNumber, + source = if (va?.manual == true) "вручную" else "авто", + submissionId = submissionsByStudent[s.id]?.id, + submissionSeq = submissionsByStudent[s.id]?.seq, + submissionFiles = submissionsByStudent[s.id]?.files ?: emptyList(), + ) + }, + ) + } + + model.addAttribute("lab", lab) + model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) + model.addAttribute( + "deadlineMsk", + ru.ysu.lms.web.DateUtils + .formatMsk(lab.deadlineAt), + ) + model.addAttribute( + "defenseMsk", + ru.ysu.lms.web.DateUtils + .formatMsk(lab.defenseAt), + ) + model.addAttribute( + "deadlineLocal", + ru.ysu.lms.web.DateUtils + .localInput(lab.deadlineAt), + ) + model.addAttribute( + "defenseLocal", + ru.ysu.lms.web.DateUtils + .localInput(lab.defenseAt), + ) + model.addAttribute("assignedCount", assignedCount) + model.addAttribute("submissionCount", submissionsByStudent.size) + model.addAttribute("assignmentGroups", assignmentGroups) + model.addAttribute("groups", api.listGroups(t)) + model.addAttribute("variantUsage", variantUsage) + model.addAttribute("totalVariants", totalVariants) + model.addAttribute("occupiedVariants", occupiedVariants) + model.addAttribute("freeVariants", totalVariants - occupiedVariants) + return "admin/lab" + } + + @PostMapping("/labs/{labId}/assign") + fun assignVariant( + @PathVariable labId: String, + @RequestParam studentId: String, + @RequestParam variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId)) + redirect.addFlashAttribute("notice", "Назначен вариант ${result.variantNumber}") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/create") + fun createLab( + @RequestParam number: Int, + @RequestParam title: String, + @RequestParam(required = false) deadlineAt: String?, + @RequestParam(required = false) defenseAt: String?, + @RequestParam pointsMax: String, + @RequestParam status: String, + @RequestParam descriptionMd: String, + @RequestParam levels: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + // levels: строки "код;название" по одной на строку + val levelList = + levels + .lines() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapIndexed { idx, line -> + val parts = line.split(";", limit = 2) + LevelPayload( + code = parts[0].trim(), + title = parts.getOrNull(1)?.trim().takeUnless { it.isNullOrBlank() } ?: parts[0].trim(), + position = idx + 1, + ) + } + val created = + api.createLab( + token(session), + LabCreatePayload( + number = number, + title = title.trim(), + deadlineAt = + ru.ysu.lms.web.DateUtils + .toUtcIso(deadlineAt), + defenseAt = + ru.ysu.lms.web.DateUtils + .toUtcIso(defenseAt), + pointsMax = java.math.BigDecimal(pointsMax), + status = status, + descriptionMd = descriptionMd, + levels = levelList, + ), + ) + redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана") + "redirect:/admin/labs/${created.id}" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + + @PostMapping("/labs/{labId}/levels/save") + fun saveLevel( + @PathVariable labId: String, + @RequestParam(required = false) levelId: String?, + @RequestParam code: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + if (levelId.isNullOrBlank()) { + api.addLevel(token(session), labId, LevelSavePayload(code, title)) + redirect.addFlashAttribute("notice", "Уровень добавлен") + } else { + api.renameLevel(token(session), labId, levelId, title) + redirect.addFlashAttribute("notice", "Уровень обновлён") + } + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/{labId}/levels/{levelId}/delete") + fun deleteLevel( + @PathVariable labId: String, + @PathVariable levelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.deleteLevel(token(session), labId, levelId) + redirect.addFlashAttribute("notice", "Уровень удалён") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @GetMapping("/labs/{id}/edit") + fun editLabForm( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val lab = api.labDetail(token(session), id) + // datetime-local требует формат yyyy-MM-dd'T'HH:mm + model.addAttribute("lab", lab) + model.addAttribute( + "deadlineLocal", + ru.ysu.lms.web.DateUtils + .localInput(lab.deadlineAt), + ) + model.addAttribute( + "defenseLocal", + ru.ysu.lms.web.DateUtils + .localInput(lab.defenseAt), + ) + return "admin/lab-edit" + } + + /** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */ + @PostMapping("/labs/{id}/edit") + fun editLab( + @PathVariable id: String, + @RequestParam title: String, + @RequestParam(required = false) deadlineAt: String?, + @RequestParam(required = false) defenseAt: String?, + @RequestParam pointsMax: String, + @RequestParam status: String, + @RequestParam descriptionMd: String, + @RequestParam(defaultValue = "save") action: String, + @RequestParam(required = false) levelIds: List?, + @RequestParam(required = false) levelTitles: List?, + @RequestParam(required = false) variantIds: List?, + @RequestParam(required = false) variantLevelIds: List?, + @RequestParam(required = false) variantNumbers: List?, + @RequestParam(required = false) variantTitles: List?, + @RequestParam(required = false) variantStatements: List?, + @RequestParam(required = false) newLevelCode: String?, + @RequestParam(required = false) newLevelTitle: String?, + @RequestParam(required = false) addLevelId: String?, + @RequestParam(required = false) addNumber: String?, + @RequestParam(required = false) addTitle: String?, + @RequestParam(required = false) addStatementMd: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val t = token(session) + return try { + when { + action.startsWith("delete-level-") -> { + api.deleteLevel(t, id, action.removePrefix("delete-level-")) + redirect.addFlashAttribute("notice", "Уровень удалён") + return "redirect:/admin/labs/$id/edit" + } + action.startsWith("delete-variant-") -> { + api.deleteVariant(t, id, action.removePrefix("delete-variant-")) + redirect.addFlashAttribute("notice", "Вариант удалён") + return "redirect:/admin/labs/$id/edit" + } + } + + api.updateLab( + t, + id, + LabUpdatePayload( + title, + ru.ysu.lms.web.DateUtils + .toUtcIso(deadlineAt), + ru.ysu.lms.web.DateUtils + .toUtcIso(defenseAt), + pointsMax, + status, + descriptionMd, + ), + ) + levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) -> + if (levelId.isNotBlank() && newTitle.isNotBlank()) { + api.renameLevel(t, id, levelId, newTitle.trim()) + } + } + variantIds.orEmpty().forEachIndexed { idx, variantId -> + val number = + variantNumbers + .orEmpty() + .getOrNull(idx) + .orEmpty() + .trim() + if (variantId.isNotBlank() && number.isNotBlank()) { + api.saveVariant( + t, + id, + variantId, + VariantSavePayload( + variantLevelIds.orEmpty().getOrNull(idx).orEmpty(), + number, + variantTitles + .orEmpty() + .getOrNull(idx) + .orEmpty() + .trim(), + variantStatements.orEmpty().getOrNull(idx).orEmpty(), + ), + ) + } + } + when { + action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> { + api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim())) + redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен") + } + action == "add-variant" && !addLevelId.isNullOrBlank() && !addNumber.isNullOrBlank() -> { + api.saveVariant( + t, + id, + null, + VariantSavePayload( + addLevelId, + addNumber.trim(), + addTitle.orEmpty().trim(), + addStatementMd.orEmpty(), + ), + ) + redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен") + } + else -> redirect.addFlashAttribute("notice", "Все изменения сохранены") + } + "redirect:/admin/labs/$id/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id/edit" + } + } + + @PostMapping("/labs/{labId}/variants/save") + fun saveVariant( + @PathVariable labId: String, + @RequestParam(required = false) variantId: String?, + @RequestParam labLevelId: String, + @RequestParam number: String, + @RequestParam title: String, + @RequestParam statementMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.saveVariant(token(session), labId, variantId, VariantSavePayload(labLevelId, number, title, statementMd)) + redirect.addFlashAttribute("notice", "Вариант сохранён") + "redirect:/admin/labs/$labId/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId/edit" + } + + @PostMapping("/labs/{labId}/variants/{variantId}/delete") + fun deleteVariant( + @PathVariable labId: String, + @PathVariable variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.deleteVariant(token(session), labId, variantId) + redirect.addFlashAttribute("notice", "Вариант удалён") + "redirect:/admin/labs/$labId/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId/edit" + } + + @PostMapping("/labs/{labId}/set-level") + fun setLevel( + @PathVariable labId: String, + @RequestParam studentId: String, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) + redirect.addFlashAttribute("notice", "Уровень студента установлен") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt new file mode 100644 index 0000000..b3252b7 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt @@ -0,0 +1,128 @@ +package ru.ysu.lms.web.admin + +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.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.CreateStudentRequestDto +import ru.ysu.lms.web.api.createStudent +import ru.ysu.lms.web.api.credentials +import ru.ysu.lms.web.api.enrollStudent +import ru.ysu.lms.web.api.resetPassword +import ru.ysu.lms.web.api.unenrollStudent +import tools.jackson.databind.ObjectMapper +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +@Controller +@RequestMapping("/admin") +class AdminStudentController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @PostMapping("/groups/{id}/students/{studentId}/reset-password") + fun resetPassword( + @PathVariable id: String, + @PathVariable studentId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = api.resetPassword(token(session), studentId) + redirect.addFlashAttribute("resetPassword", result.password) + redirect.addFlashAttribute("resetFor", studentId) + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + + @GetMapping("/groups/{id}/credentials") + fun credentials( + @PathVariable id: String, + @RequestParam(defaultValue = "xlsx") format: String, + session: HttpSession, + ): ResponseEntity { + val download = api.credentials(token(session), id, format) + val encoded = URLEncoder.encode(download.filename, StandardCharsets.UTF_8).replace("+", "%20") + return ResponseEntity + .ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") + .contentType(MediaType.parseMediaType(download.contentType)) + .body(download.bytes) + } + + @PostMapping("/groups/{groupId}/students/create") + fun createStudent( + @PathVariable groupId: String, + @RequestParam studentNo: String, + @RequestParam lastName: String, + @RequestParam(required = false) firstName: String?, + @RequestParam(required = false) middleName: String?, + @RequestParam(required = false) basis: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = + api.createStudent( + token(session), + groupId, + CreateStudentRequestDto( + studentNo = studentNo.trim(), + lastName = lastName.trim(), + firstName = firstName.orEmpty().trim(), + middleName = middleName.orEmpty().trim(), + basis = basis.orEmpty().trim(), + ), + ) + redirect.addFlashAttribute("createdStudent", result) + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } + + @PostMapping("/groups/{groupId}/students/enroll") + fun enrollStudent( + @PathVariable groupId: String, + @RequestParam studentId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.enrollStudent(token(session), groupId, studentId) + redirect.addFlashAttribute("notice", "Студент зачислен в группу") + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } + + @PostMapping("/groups/{groupId}/students/{studentId}/unenroll") + fun unenrollStudent( + @PathVariable groupId: String, + @PathVariable studentId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.unenrollStudent(token(session), groupId, studentId) + redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)") + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt new file mode 100644 index 0000000..44c7f61 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt @@ -0,0 +1,267 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.LabSummaryDto +import ru.ysu.lms.web.api.StudentRowDto +import ru.ysu.lms.web.api.assignDefense +import ru.ysu.lms.web.api.downloadSubmissionAsAdmin +import ru.ysu.lms.web.api.groupDetail +import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.session.SessionKeys +import tools.jackson.databind.ObjectMapper +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +abstract class AdminSupport( + protected val api: ApiClient, + protected val markdown: MarkdownRenderer, + protected val mapper: ObjectMapper, +) { + protected fun importDefenses( + t: String, + labId: String?, + file: MultipartFile, + groupId: String?, + redirect: RedirectAttributes, + ): String = + try { + val studentByNo = mutableMapOf() + for (g in api.listGroups(t)) { + api.groupDetail(t, g.id).students.forEach { studentByNo[it.studentNo] = it.id } + } + val labByNumber = api.listLabs(t).associateBy { it.number.toString() } + val rows = parseCsv(file.bytes) + var count = 0 + var skipped = 0 + for ((idx, rawCols) in rows.withIndex()) { + val row = resolveDefenseRow(rawCols, idx, labId, studentByNo, labByNumber) + if (row == null) { + skipped++ + } else { + api.assignDefense(t, row.studentId, row.labId, row.assignmentMd.replace("\\n", "\n")) + count++ + } + } + val tail = if (skipped > 0) ", пропущено: $skipped" else "" + redirect.addFlashAttribute("notice", "Импортировано заданий: $count$tail") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + protected fun resolveDefenseRow( + rawCols: List, + index: Int, + defaultLabId: String?, + studentByNo: Map, + labByNumber: Map, + ): DefenseRow? { + val cells = rawCols.map { it.trim() } + if (cells.all { it.isBlank() }) return null + if (index == 0 && looksLikeHeader(cells)) return null + var sid: String? = null + var lid: String? = defaultLabId?.takeIf { it.isNotBlank() } + var md: String? = null + when { + cells.size >= 3 -> { + sid = studentByNo[cells[0]] + lid = labByNumber[cells[1]]?.id ?: lid + md = cells.drop(2).joinToString("; ") + } + cells.size == 2 -> { + sid = studentByNo[cells[0]] + md = cells[1] + } + } + if (sid == null || lid.isNullOrBlank() || md.isNullOrBlank()) return null + return DefenseRow(sid, lid, md) + } + + protected fun looksLikeHeader(cells: List): Boolean { + val joined = cells.joinToString(" ").lowercase() + return joined.contains("студент") || + joined.contains("student") || + joined.contains("фио") || + joined.contains("задан") || + joined.contains("assignment") + } + + /** Простой CSV: разделитель ; , или таб; поддержка кавычек и экранирования "" */ + protected fun parseCsv(bytes: ByteArray): List> { + val text = String(bytes, Charsets.UTF_8).removePrefix("\uFEFF") + val lines = text.lines().filter { it.isNotBlank() } + val sample = lines.firstOrNull() ?: return emptyList() + val delimiter = + when { + sample.contains('\t') -> '\t' + sample.contains(';') -> ';' + else -> ',' + } + return lines.map { splitCsvLine(it, delimiter) } + } + + protected fun splitCsvLine( + line: String, + delimiter: Char, + ): List { + val out = mutableListOf() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val c = line[i] + when { + inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> { + sb.append('"') + i++ + } + inQuotes && c == '"' -> inQuotes = false + inQuotes -> sb.append(c) + c == '"' -> inQuotes = true + c == delimiter -> { + out.add(sb.toString()) + sb.setLength(0) + } + else -> sb.append(c) + } + i++ + } + out.add(sb.toString()) + return out + } + + protected fun redirectToGroup( + groupId: String?, + labId: String?, + ): String = + when { + !groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId" + !labId.isNullOrBlank() -> "redirect:/admin/labs/$labId" + else -> "redirect:/admin/groups" + } + + protected fun buildGroupArchive( + token: String, + groupName: String, + studentById: Map, + labs: List, + ): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zos -> + zos.putNextEntry(ZipEntry("ОПИСАНИЕ.txt").apply { time = 0 }) + zos.write("Архив сдач группы $groupName (последние попытки)\n".toByteArray(Charsets.UTF_8)) + zos.closeEntry() + for (lab in labs) { + for (sub in api.labSubmissions(token, lab.id)) { + val student = studentById[sub.studentId] ?: continue + val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes + appendSubmissionArchive(zos, lab, student, bytes) + } + } + } + return out.toByteArray() + } + + protected fun appendSubmissionArchive( + zos: ZipOutputStream, + lab: LabSummaryDto, + student: StudentRowDto, + submissionZip: ByteArray, + ) { + ZipInputStream(ByteArrayInputStream(submissionZip)).use { zin -> + var entry = zin.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + val safeName = student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + zos.putNextEntry(ZipEntry("ЛР${lab.number}/${student.studentNo}_$safeName/${entry.name}").apply { time = 0 }) + zin.copyTo(zos) + zos.closeEntry() + } + entry = zin.nextEntry + } + } + } + + protected fun zipAttachment( + name: String, + bytes: ByteArray, + ): ResponseEntity { + val encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) + return ResponseEntity + .ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(bytes) + } + + /** Человекочитаемое имя архива сдачи: группа-номер студенческого-ФИО-попыткаN.zip */ + protected fun submissionFilename( + group: String?, + no: String?, + name: String?, + attempt: String?, + ): String? { + val parts = + listOfNotNull( + group?.takeIf { it.isNotBlank() }, + no?.takeIf { it.isNotBlank() }, + name?.takeIf { it.isNotBlank() }, + 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" + } + + protected fun parseComments(json: String): List { + if (json.isBlank() || json == "[]") return emptyList() + return try { + val node = mapper.readTree(json) + buildList { + node.forEach { el -> + val text = el.path("text").asString("").trim() + if (text.isNotEmpty()) { + add( + ru.ysu.lms.web.api + .CommentPayload(text, el.path("tone").asString("neutral")), + ) + } + } + } + } catch (_: Exception) { + emptyList() + } + } + + protected fun token(session: HttpSession): String = + session.getAttribute(SessionKeys.TOKEN) as? String + ?: throw ApiException(401, "Сессия истекла, войдите заново") + + protected data class DefenseRow( + val studentId: String, + val labId: String, + val assignmentMd: String, + ) + + /** Разбирает строку CSV с заданием на защиту; null — строку нужно пропустить. */ +}