Refactor: split AdminController into AdminSupport and domain controllers

This commit is contained in:
2026-09-27 22:36:41 +03:00
parent f08ab16af0
commit 3ed48b35fc
7 changed files with 1470 additions and 1336 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}

View File

@@ -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<String>,
@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<String>,
@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<String>?,
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"
}
}

View File

@@ -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<ByteArray> {
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<ByteArray> {
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)
}
}

View File

@@ -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<String, Int>()
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<String>?,
@RequestParam(required = false) levelTitles: List<String>?,
@RequestParam(required = false) variantIds: List<String>?,
@RequestParam(required = false) variantLevelIds: List<String>?,
@RequestParam(required = false) variantNumbers: List<String>?,
@RequestParam(required = false) variantTitles: List<String>?,
@RequestParam(required = false) variantStatements: List<String>?,
@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"
}
}

View File

@@ -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<ByteArray> {
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"
}
}

View File

@@ -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<String, String>()
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<String>,
index: Int,
defaultLabId: String?,
studentByNo: Map<String, String>,
labByNumber: Map<String, LabSummaryDto>,
): 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<String>): 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<List<String>> {
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<String> {
val out = mutableListOf<String>()
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<String, StudentRowDto>,
labs: List<LabSummaryDto>,
): 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<ByteArray> {
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<ru.ysu.lms.web.api.CommentPayload> {
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 — строку нужно пропустить. */
}