Refactor: extract helpers from groupDetail/editLab; detekt thresholds to defaults

This commit is contained in:
2026-09-27 22:58:35 +03:00
parent 4035bc5a0f
commit 6129055962
3 changed files with 175 additions and 101 deletions

View File

@@ -9,15 +9,15 @@ config:
warningsAsErrors: false
complexity:
# Крупные агрегаты уже раздроблены (web ApiClient — по доменам, AdminController —
# на AdminSupport + 5 контроллеров, импорт материалов — в ContentImporter).
# Оставшиеся послабления точечные и перечислены ниже; после дробления ContentService
# и выноса хелперов из методов groupDetail/labDetail/editLab их можно снять.
# Все правила сложности — на значениях по умолчанию: крупные агрегаты раздроблены
# (web ApiClient — по доменам, AdminController — AdminSupport + 5 контроллеров,
# импорт — ContentImporter, авторинг/порядок и оценки — отдельные сервисы,
# submissions — отдельный контроллер). Единственное ослабление — LongParameterList
# для MVC-форм (см. ниже): у обработчиков формы @RequestParam естественно много.
LongMethod:
# groupDetail (82), labDetail (73), editLab (86) в админ-контроллерах.
threshold: 90
threshold: 60
LongParameterList:
# MVC-обработчики форм принимают набор @RequestParam — для них это нормально.
# MVC-обработчики форм принимают набор @RequestParam — параметров объективно много.
functionThreshold: 8
constructorThreshold: 8
ignoreDefaultParameters: true
@@ -25,7 +25,6 @@ complexity:
- 'PostMapping'
- 'GetMapping'
TooManyFunctions:
# ContentService (26) — следующий кандидат на разделение.
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
@@ -36,8 +35,7 @@ complexity:
ComplexCondition:
threshold: 4
CyclomaticComplexMethod:
# editLab (16) в AdminLabController.
threshold: 17
threshold: 15
style:
MagicNumber:

View File

@@ -17,6 +17,9 @@ 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.GroupDetailDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
import ru.ysu.lms.web.api.getLabAssignments
@@ -128,8 +131,7 @@ class AdminGroupController(
model: Model,
): String {
val t = token(session)
val group = api.groupDetail(t, id)
val labs = api.listLabs(t).filter { it.status == "published" }
val (group, labs) = loadGroupAndLabs(t, id)
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 } }
@@ -183,33 +185,59 @@ class AdminGroupController(
}
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 }))
buildDefenseRows(t, group, labs)
val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())
fillGroupModel(model, group, labs, candidates, rows, defenseRows, publishedLabs)
return "admin/group"
}
private fun loadGroupAndLabs(
token: String,
id: String,
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
/** Строки для блока «Защиты»: сессии защиты по всем лабораторным группы. */
private fun buildDefenseRows(
token: String,
group: GroupDetailDto,
labs: List<LabSummaryDto>,
): List<GroupDefenseRow> =
labs
.flatMap { lab ->
val byStudent = api.labDefenses(token, 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 }))
private fun fillGroupModel(
model: Model,
group: GroupDetailDto,
labs: List<LabSummaryDto>,
candidates: List<StudentCandidateDto>,
rows: List<GroupStudentRow>,
defenseRows: List<GroupDefenseRow>,
publishedLabs: Set<String>,
) {
model.addAttribute("group", group)
model.addAttribute("labs", labs)
model.addAttribute("candidates", runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList()))
model.addAttribute("candidates", candidates)
model.addAttribute("studentGrades", rows)
model.addAttribute("defenseRows", defenseRows)
model.addAttribute("publishedLabIds", publishedLabs)
return "admin/group"
}
@PostMapping("/groups/{id}/grades")

View File

@@ -15,10 +15,15 @@ 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.LabDetailDto
import ru.ysu.lms.web.api.LabLevelDetailDto
import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.LevelAssignPayload
import ru.ysu.lms.web.api.LevelAssignmentResultDto
import ru.ysu.lms.web.api.LevelPayload
import ru.ysu.lms.web.api.LevelSavePayload
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.addLevel
import ru.ysu.lms.web.api.assignVariant
@@ -136,32 +141,7 @@ class AdminLabController(
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(),
)
},
)
}
buildAssignmentGroups(t, lab, levelByStudent, assignmentByStudent, submissionsByStudent, defaultLevel)
model.addAttribute("lab", lab)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
@@ -196,6 +176,42 @@ class AdminLabController(
return "admin/lab"
}
/** Строки «группа → студенты» со их уровнем, вариантом и последней сдачей. */
private fun buildAssignmentGroups(
token: String,
lab: LabDetailDto,
levelByStudent: Map<String, LevelAssignmentResultDto>,
assignmentByStudent: Map<String, VariantAssignmentResultDto>,
submissionsByStudent: Map<String, SubmissionDto>,
defaultLevel: LabLevelDetailDto?,
): List<AdminLabGroup> =
api.listGroups(token).map { g ->
val detail = api.groupDetail(token, 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(),
)
},
)
}
@PostMapping("/labs/{labId}/assign")
fun assignVariant(
@PathVariable labId: String,
@@ -306,6 +322,77 @@ class AdminLabController(
}
@GetMapping("/labs/{id}/edit")
/** Быстрые действия «удалить уровень/вариант» на форме лабы; null — обычное сохранение. */
private fun handleEditDeleteAction(
token: String,
id: String,
action: String,
redirect: RedirectAttributes,
): String? =
when {
action.startsWith("delete-level-") -> {
api.deleteLevel(token, id, action.removePrefix("delete-level-"))
redirect.addFlashAttribute("notice", "Уровень удалён")
"redirect:/admin/labs/$id/edit"
}
action.startsWith("delete-variant-") -> {
api.deleteVariant(token, id, action.removePrefix("delete-variant-"))
redirect.addFlashAttribute("notice", "Вариант удалён")
"redirect:/admin/labs/$id/edit"
}
else -> null
}
private fun applyLevelRenames(
token: String,
id: String,
levelIds: List<String>?,
levelTitles: List<String>?,
) {
levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) ->
if (levelId.isNotBlank() && newTitle.isNotBlank()) {
api.renameLevel(token, id, levelId, newTitle.trim())
}
}
}
@Suppress("LongParameterList")
private fun applyVariantSaves(
token: String,
id: String,
variantIds: List<String>?,
variantNumbers: List<String>?,
variantLevelIds: List<String>?,
variantTitles: List<String>?,
variantStatements: List<String>?,
) {
variantIds.orEmpty().forEachIndexed { idx, variantId ->
val number =
variantNumbers
.orEmpty()
.getOrNull(idx)
.orEmpty()
.trim()
if (variantId.isNotBlank() && number.isNotBlank()) {
api.saveVariant(
token,
id,
variantId,
VariantSavePayload(
variantLevelIds.orEmpty().getOrNull(idx).orEmpty(),
number,
variantTitles
.orEmpty()
.getOrNull(idx)
.orEmpty()
.trim(),
variantStatements.orEmpty().getOrNull(idx).orEmpty(),
),
)
}
}
}
fun editLabForm(
@PathVariable id: String,
session: HttpSession,
@@ -356,18 +443,7 @@ class AdminLabController(
): 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"
}
}
handleEditDeleteAction(t, id, action, redirect)?.let { return it }
api.updateLab(
t,
@@ -383,36 +459,8 @@ class AdminLabController(
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(),
),
)
}
}
applyLevelRenames(t, id, levelIds, levelTitles)
applyVariantSaves(t, id, variantIds, variantNumbers, variantLevelIds, variantTitles, variantStatements)
when {
action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> {
api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim()))