Refactor: bind admin forms via @ModelAttribute DTOs; detekt fully default

This commit is contained in:
2026-09-27 23:13:29 +03:00
parent 42664297de
commit d3f70b0a25
11 changed files with 255 additions and 185 deletions

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -26,38 +27,34 @@ class AdminDefenseController(
@PostMapping("/labs/{labId}/defense/assign")
fun assignDefense(
@PathVariable labId: String,
@RequestParam studentId: String,
@RequestParam assignmentMd: String,
@RequestParam(required = false) groupId: String?,
@ModelAttribute form: DefenseForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), studentId, labId, assignmentMd)
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(groupId, labId)
redirectToGroup(form.groupId, labId)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId)
redirectToGroup(form.groupId, labId)
}
/** Назначение/правка индивидуального задания со страницы группы. */
@PostMapping("/defenses/save")
fun saveDefense(
@RequestParam studentId: String,
@ModelAttribute form: DefenseForm,
@RequestParam labId: String,
@RequestParam assignmentMd: String,
@RequestParam(required = false) groupId: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), studentId, labId, assignmentMd)
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(groupId, labId)
redirectToGroup(form.groupId, labId)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId)
redirectToGroup(form.groupId, labId)
}
@PostMapping("/labs/{labId}/defense/import")

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -29,14 +30,12 @@ class AdminDistributeController(
@PostMapping("/groups/{gid}/set-level")
fun setStudentLevel(
@PathVariable gid: String,
@RequestParam studentId: String,
@RequestParam labId: String,
@RequestParam labLevelId: String,
@ModelAttribute form: StudentLevelForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId))
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
"redirect:/admin/groups/$gid"
} catch (ex: ApiException) {
@@ -47,14 +46,12 @@ class AdminDistributeController(
@PostMapping("/groups/{gid}/pin-variant")
fun pinStudentVariant(
@PathVariable gid: String,
@RequestParam studentId: String,
@RequestParam labId: String,
@RequestParam variantId: String,
@ModelAttribute form: PinVariantForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId))
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
"redirect:/admin/groups/$gid"
} catch (ex: ApiException) {

View File

@@ -0,0 +1,92 @@
package ru.ysu.lms.web.admin
// Формы админ-контроллеров: связываются через @ModelAttribute.
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @RequestParam).
data class CreateStudentForm(
val studentNo: String = "",
val lastName: String = "",
val firstName: String? = null,
val middleName: String? = null,
val basis: String? = null,
)
data class StudentLevelForm(
val studentId: String = "",
val labId: String = "",
val labLevelId: String = "",
)
data class PinVariantForm(
val studentId: String = "",
val labId: String = "",
val variantId: String = "",
)
data class DefenseForm(
val studentId: String = "",
val assignmentMd: String = "",
val groupId: String? = null,
)
data class CreateLabForm(
val number: Int = 0,
val title: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: String = "",
val status: String = "",
val descriptionMd: String = "",
val levels: String = "",
)
data class SaveLevelForm(
val levelId: String? = null,
val code: String = "",
val title: String = "",
)
data class SaveVariantForm(
val variantId: String? = null,
val labLevelId: String = "",
val number: String = "",
val title: String = "",
val statementMd: String = "",
)
data class EditLabForm(
val title: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: String = "",
val status: String = "",
val descriptionMd: String = "",
val action: String = "save",
val levelIds: List<String>? = null,
val levelTitles: List<String>? = null,
val variantIds: List<String>? = null,
val variantLevelIds: List<String>? = null,
val variantNumbers: List<String>? = null,
val variantTitles: List<String>? = null,
val variantStatements: List<String>? = null,
val newLevelCode: String? = null,
val newLevelTitle: String? = null,
val addLevelId: String? = null,
val addNumber: String? = null,
val addTitle: String? = null,
val addStatementMd: String? = null,
)
data class SetGradeForm(
val studentId: String = "",
val labId: String = "",
val score: String = "",
val commentsJson: String? = null,
)
data class SubmissionDownloadForm(
val group: String? = null,
val no: String? = null,
val name: String? = null,
val attempt: String? = null,
)

View File

@@ -7,6 +7,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -188,7 +189,7 @@ class AdminGroupController(
buildDefenseRows(t, group, labs)
val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())
fillGroupModel(model, group, labs, candidates, rows, defenseRows, publishedLabs)
fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, defenseRows, publishedLabs))
return "admin/group"
}
@@ -223,36 +224,37 @@ class AdminGroupController(
}
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber }))
private data class GroupViewModel(
val group: GroupDetailDto,
val labs: List<LabSummaryDto>,
val candidates: List<StudentCandidateDto>,
val rows: List<GroupStudentRow>,
val defenseRows: List<GroupDefenseRow>,
val publishedLabs: Set<String>,
)
private fun fillGroupModel(
model: Model,
group: GroupDetailDto,
labs: List<LabSummaryDto>,
candidates: List<StudentCandidateDto>,
rows: List<GroupStudentRow>,
defenseRows: List<GroupDefenseRow>,
publishedLabs: Set<String>,
view: GroupViewModel,
) {
model.addAttribute("group", group)
model.addAttribute("labs", labs)
model.addAttribute("candidates", candidates)
model.addAttribute("studentGrades", rows)
model.addAttribute("defenseRows", defenseRows)
model.addAttribute("publishedLabIds", publishedLabs)
model.addAttribute("group", view.group)
model.addAttribute("labs", view.labs)
model.addAttribute("candidates", view.candidates)
model.addAttribute("studentGrades", view.rows)
model.addAttribute("defenseRows", view.defenseRows)
model.addAttribute("publishedLabIds", view.publishedLabs)
}
@PostMapping("/groups/{id}/grades")
fun setGrade(
@PathVariable id: String,
@RequestParam studentId: String,
@RequestParam labId: String,
@RequestParam score: String,
@RequestParam(required = false) commentsJson: String?,
@ModelAttribute form: SetGradeForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val comments = parseComments(commentsJson ?: "[]")
api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments))
val comments = parseComments(form.commentsJson ?: "[]")
api.setGrade(token(session), GradeSetPayload(form.studentId, form.labId, form.score, comments))
redirect.addFlashAttribute("notice", "Оценка сохранена")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
@@ -294,14 +296,11 @@ class AdminGroupController(
@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?,
@ModelAttribute form: SubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> {
val file = api.downloadSubmissionAsAdmin(token(session), id)
val filename = submissionFilename(group, no, name, attempt) ?: file.filename
val filename = submissionFilename(form.group, form.no, form.name, form.attempt) ?: file.filename
val encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8)
return ResponseEntity
.ok()

View File

@@ -4,6 +4,7 @@ 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.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -141,7 +142,10 @@ class AdminLabController(
val defaultLevel = lab.levels.firstOrNull()
val assignmentGroups =
buildAssignmentGroups(t, lab, levelByStudent, assignmentByStudent, submissionsByStudent, defaultLevel)
buildAssignmentGroups(
t,
LabAssignmentData(lab, levelByStudent, assignmentByStudent, submissionsByStudent, defaultLevel),
)
model.addAttribute("lab", lab)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
@@ -176,14 +180,18 @@ class AdminLabController(
return "admin/lab"
}
private data class LabAssignmentData(
val lab: LabDetailDto,
val levelByStudent: Map<String, LevelAssignmentResultDto>,
val assignmentByStudent: Map<String, VariantAssignmentResultDto>,
val submissionsByStudent: Map<String, SubmissionDto>,
val defaultLevel: LabLevelDetailDto?,
)
/** Строки «группа → студенты» со их уровнем, вариантом и последней сдачей. */
private fun buildAssignmentGroups(
token: String,
lab: LabDetailDto,
levelByStudent: Map<String, LevelAssignmentResultDto>,
assignmentByStudent: Map<String, VariantAssignmentResultDto>,
submissionsByStudent: Map<String, SubmissionDto>,
defaultLevel: LabLevelDetailDto?,
data: LabAssignmentData,
): List<AdminLabGroup> =
api.listGroups(token).map { g ->
val detail = api.groupDetail(token, g.id)
@@ -191,9 +199,9 @@ class AdminLabController(
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]
val levelState = data.levelByStudent[s.id]
val level = data.lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: data.defaultLevel
val va = data.assignmentByStudent[s.id]
AdminLabStudent(
studentId = s.id,
fullName = s.fullName,
@@ -204,9 +212,9 @@ class AdminLabController(
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(),
submissionId = data.submissionsByStudent[s.id]?.id,
submissionSeq = data.submissionsByStudent[s.id]?.seq,
submissionFiles = data.submissionsByStudent[s.id]?.files ?: emptyList(),
)
},
)
@@ -231,21 +239,14 @@ class AdminLabController(
@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,
@ModelAttribute form: CreateLabForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
// levels: строки "код;название" по одной на строку
val levelList =
levels
form.levels
.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
@@ -261,17 +262,17 @@ class AdminLabController(
api.createLab(
token(session),
LabCreatePayload(
number = number,
title = title.trim(),
number = form.number,
title = form.title.trim(),
deadlineAt =
ru.ysu.lms.web.DateUtils
.toUtcIso(deadlineAt),
.toUtcIso(form.deadlineAt),
defenseAt =
ru.ysu.lms.web.DateUtils
.toUtcIso(defenseAt),
pointsMax = java.math.BigDecimal(pointsMax),
status = status,
descriptionMd = descriptionMd,
.toUtcIso(form.defenseAt),
pointsMax = java.math.BigDecimal(form.pointsMax),
status = form.status,
descriptionMd = form.descriptionMd,
levels = levelList,
),
)
@@ -285,18 +286,16 @@ class AdminLabController(
@PostMapping("/labs/{labId}/levels/save")
fun saveLevel(
@PathVariable labId: String,
@RequestParam(required = false) levelId: String?,
@RequestParam code: String,
@RequestParam title: String,
@ModelAttribute form: SaveLevelForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
if (levelId.isNullOrBlank()) {
api.addLevel(token(session), labId, LevelSavePayload(code, title))
if (form.levelId.isNullOrBlank()) {
api.addLevel(token(session), labId, LevelSavePayload(form.code, form.title))
redirect.addFlashAttribute("notice", "Уровень добавлен")
} else {
api.renameLevel(token(session), labId, levelId, title)
api.renameLevel(token(session), labId, form.levelId, form.title)
redirect.addFlashAttribute("notice", "Уровень обновлён")
}
"redirect:/admin/labs/$labId"
@@ -418,64 +417,45 @@ class AdminLabController(
@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?,
@ModelAttribute form: EditLabForm,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val t = token(session)
return try {
handleEditDeleteAction(t, id, action, redirect)?.let { return it }
handleEditDeleteAction(t, id, form.action, redirect)?.let { return it }
api.updateLab(
t,
id,
LabUpdatePayload(
title,
form.title,
ru.ysu.lms.web.DateUtils
.toUtcIso(deadlineAt),
.toUtcIso(form.deadlineAt),
ru.ysu.lms.web.DateUtils
.toUtcIso(defenseAt),
pointsMax,
status,
descriptionMd,
.toUtcIso(form.defenseAt),
form.pointsMax,
form.status,
form.descriptionMd,
),
)
applyLevelRenames(t, id, levelIds, levelTitles)
applyVariantSaves(t, id, variantIds, variantNumbers, variantLevelIds, variantTitles, variantStatements)
applyLevelRenames(t, id, form.levelIds, form.levelTitles)
applyVariantSaves(t, id, form.variantIds, form.variantNumbers, form.variantLevelIds, form.variantTitles, form.variantStatements)
when {
action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> {
api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim()))
form.action == "add-level" && !form.newLevelCode.isNullOrBlank() && !form.newLevelTitle.isNullOrBlank() -> {
api.addLevel(t, id, LevelSavePayload(form.newLevelCode.trim(), form.newLevelTitle.trim()))
redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен")
}
action == "add-variant" && !addLevelId.isNullOrBlank() && !addNumber.isNullOrBlank() -> {
form.action == "add-variant" && !form.addLevelId.isNullOrBlank() && !form.addNumber.isNullOrBlank() -> {
api.saveVariant(
t,
id,
null,
VariantSavePayload(
addLevelId,
addNumber.trim(),
addTitle.orEmpty().trim(),
addStatementMd.orEmpty(),
form.addLevelId,
form.addNumber.trim(),
form.addTitle.orEmpty().trim(),
form.addStatementMd.orEmpty(),
),
)
redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен")
@@ -492,16 +472,17 @@ class AdminLabController(
@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,
@ModelAttribute form: SaveVariantForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.saveVariant(token(session), labId, variantId, VariantSavePayload(labLevelId, number, title, statementMd))
api.saveVariant(
token(session),
labId,
form.variantId,
VariantSavePayload(form.labLevelId, form.number, form.title, form.statementMd),
)
redirect.addFlashAttribute("notice", "Вариант сохранён")
"redirect:/admin/labs/$labId/edit"
} catch (ex: ApiException) {

View File

@@ -6,6 +6,7 @@ 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.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -66,11 +67,7 @@ class AdminStudentController(
@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?,
@ModelAttribute form: CreateStudentForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -80,11 +77,11 @@ class AdminStudentController(
token(session),
groupId,
CreateStudentRequestDto(
studentNo = studentNo.trim(),
lastName = lastName.trim(),
firstName = firstName.orEmpty().trim(),
middleName = middleName.orEmpty().trim(),
basis = basis.orEmpty().trim(),
studentNo = form.studentNo.trim(),
lastName = form.lastName.trim(),
firstName = form.firstName.orEmpty().trim(),
middleName = form.middleName.orEmpty().trim(),
basis = form.basis.orEmpty().trim(),
),
)
redirect.addFlashAttribute("createdStudent", result)

View File

@@ -16,6 +16,7 @@ import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.ArticlesReorderDto
import ru.ysu.lms.web.api.ContentReorderDto
import ru.ysu.lms.web.api.CreateArticlePayload
import ru.ysu.lms.web.api.SectionsReorderDto
import ru.ysu.lms.web.api.createArticle
import ru.ysu.lms.web.api.createChapter
@@ -269,12 +270,14 @@ class ContentAdminController(
try {
api.createArticle(
token(session),
form.chapterId,
form.sectionId,
form.newChapterTitle?.takeIf { it.isNotBlank() },
form.newSectionTitle?.takeIf { it.isNotBlank() },
form.title.trim(),
form.bodyMd,
CreateArticlePayload(
chapterId = form.chapterId,
sectionId = form.sectionId,
newChapterTitle = form.newChapterTitle?.takeIf { it.isNotBlank() },
newSectionTitle = form.newSectionTitle?.takeIf { it.isNotBlank() },
title = form.title.trim(),
bodyMd = form.bodyMd,
),
)
redirect.addFlashAttribute("notice", "Статья «${form.title.trim()}» создана")
redirectBack(form.back, "redirect:/admin/content")

View File

@@ -231,14 +231,18 @@ fun ApiClient.deleteSectionWithMove(
}
}
data class CreateArticlePayload(
val chapterId: String?,
val sectionId: String?,
val newChapterTitle: String?,
val newSectionTitle: String?,
val title: String,
val bodyMd: String,
)
fun ApiClient.createArticle(
token: String,
chapterId: String?,
sectionId: String?,
newChapterTitle: String?,
newSectionTitle: String?,
title: String,
bodyMd: String,
payload: CreateArticlePayload,
): ContentArticleDto =
call {
client
@@ -248,12 +252,12 @@ fun ApiClient.createArticle(
.contentType(MediaType.APPLICATION_JSON)
.body(
mapOf(
"chapterId" to chapterId,
"sectionId" to sectionId,
"newChapterTitle" to newChapterTitle,
"newSectionTitle" to newSectionTitle,
"title" to title,
"bodyMd" to bodyMd,
"chapterId" to payload.chapterId,
"sectionId" to payload.sectionId,
"newChapterTitle" to payload.newChapterTitle,
"newSectionTitle" to payload.newSectionTitle,
"title" to payload.title,
"bodyMd" to payload.bodyMd,
),
).retrieve()
.body(ContentArticleDto::class.java)