Refactor: split AdminController into AdminSupport and domain controllers
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
284
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
Normal file
284
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
496
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt
Normal file
496
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
267
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt
Normal file
267
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt
Normal 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 — строку нужно пропустить. */
|
||||
}
|
||||
Reference in New Issue
Block a user