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