diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt index 6bbcd83..1d5f0b1 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt @@ -29,18 +29,8 @@ class AssetStorage( ): StoredAsset { val ext = originalName.substringAfterLast('.', "").lowercase() val isVideo = ext in VIDEO_EXTENSIONS - val isImage = ext in IMAGE_EXTENSIONS - if (!isVideo && !isImage) { - throw BadRequestException( - "Недопустимый тип файла: $originalName " + - "(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " + - "видео: ${VIDEO_EXTENSIONS.joinToString(", ")})", - ) - } - val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES - if (bytes.size > limit) { - throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)") - } + requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS) + requireSizeWithinLimit(originalName, bytes.size, isVideo) val publicName = "${UUID.randomUUID()}.$ext" val target = rootDir.resolve(publicName).normalize() if (!target.startsWith(rootDir)) { @@ -50,6 +40,31 @@ class AssetStorage( return StoredAsset(publicName, target, mimeFor(ext)) } + private fun requireAllowedType( + originalName: String, + isVideo: Boolean, + isImage: Boolean, + ) { + if (!isVideo && !isImage) { + throw BadRequestException( + "Недопустимый тип файла: $originalName " + + "(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " + + "видео: ${VIDEO_EXTENSIONS.joinToString(", ")})", + ) + } + } + + private fun requireSizeWithinLimit( + originalName: String, + size: Int, + isVideo: Boolean, + ) { + val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES + if (size > limit) { + throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)") + } + } + fun read(publicName: String): Pair? { val clean = publicName.substringAfterLast('/') val target = rootDir.resolve(clean).normalize() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index c71619c..310b7aa 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -168,12 +168,12 @@ class ContentService( articleSlug: String, includeHidden: Boolean = false, ): ContentArticleView { - val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена") - if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта") - val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден") - if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт") - val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена") - if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта") + val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена") + requireVisible(ch.published, includeHidden, "Глава скрыта") + val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден") + requireVisible(sec.published, includeHidden, "Раздел скрыт") + val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена") + requireVisible(a.published, includeHidden, "Статья скрыта") return ContentArticleView( id = a.id.toString(), chapterSlug = ch.slug, @@ -258,6 +258,19 @@ class ContentService( } } + private fun requireFound( + value: T?, + message: String, + ): T = value ?: throw NotFoundException(message) + + private fun requireVisible( + published: Boolean, + includeHidden: Boolean, + message: String, + ) { + if (!includeHidden && !published) throw NotFoundException(message) + } + private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() @Transactional diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index 77b2ff1..a589bce 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -81,20 +81,7 @@ class LabService( val levelByCode = levelEntities.associateBy { it.code } - bundle.variants.forEach { vmeta -> - val level = - levelByCode[vmeta.level] - ?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}") - variantsRepo.save( - Variant( - lab = lab, - labLevel = level, - number = vmeta.number, - title = vmeta.title, - statementMd = vmeta.statement, - ), - ) - } + saveVariants(lab, levelByCode, bundle.variants) return LabImportResult( labId = lab.id.toString(), @@ -248,10 +235,54 @@ class LabService( return detail(labId) } - @Transactional - fun create(request: LabCreateRequest): LabDetail { + private fun validateCreate(request: LabCreateRequest) { if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым") if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным") + } + + private fun saveLevels( + lab: Lab, + levels: List, + ) { + val levelMetas = levels.ifEmpty { listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) } + levelMetas.forEachIndexed { index, meta -> + if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым") + labLevels.save( + LabLevel( + lab = lab, + code = meta.code.trim(), + title = meta.title.orEmpty().ifBlank { meta.code }, + description = meta.description.orEmpty(), + position = meta.position?.takeIf { it > 0 } ?: index + 1, + ), + ) + } + } + + private fun saveVariants( + lab: Lab, + levelByCode: Map, + variantMetas: List, + ) { + variantMetas.forEach { vmeta -> + val level = + levelByCode[vmeta.level] + ?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}") + variantsRepo.save( + Variant( + lab = lab, + labLevel = level, + number = vmeta.number, + title = vmeta.title, + statementMd = vmeta.statement, + ), + ) + } + } + + @Transactional + fun create(request: LabCreateRequest): LabDetail { + validateCreate(request) val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0) if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) { throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") @@ -269,22 +300,7 @@ class LabService( status = request.status ?: "draft", ), ) - val levelMetas = - request.levels.ifEmpty { - listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) - } - levelMetas.forEachIndexed { index, meta -> - if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым") - labLevels.save( - LabLevel( - lab = lab, - code = meta.code.trim(), - title = meta.title.orEmpty().ifBlank { meta.code }, - description = meta.description.orEmpty(), - position = meta.position?.takeIf { it > 0 } ?: index + 1, - ), - ) - } + saveLevels(lab, request.levels) return detail(lab.id) } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index 457d4ac..e3ded8d 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -89,12 +89,13 @@ class GroupService( groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet() val currentByStudent = HashMap>() - for (e in enrollments.findAll()) { - val sid = e.student?.accountId ?: continue - val gid = e.group?.id ?: continue - val name = e.group?.name ?: continue - currentByStudent[sid] = gid to name - } + enrollments + .findAll() + .mapNotNull { enrollment -> + val sid = enrollment.student?.accountId ?: return@mapNotNull null + val group = enrollment.group ?: return@mapNotNull null + sid to (group.id to group.name) + }.forEach { (sid, group) -> currentByStudent[sid] = group } return students .findAll() .filter { it.accountId !in memberIds } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt index 2f1393c..8af09c7 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt @@ -92,40 +92,52 @@ class StudentImportParser { filename: String, rows: List>, ): ParsedGroup { - val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } } - if (headerIndex < 0) { - throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.") - } - val columns = mapColumns(rows[headerIndex]) - if (columns["studentNo"] == null) { - throw BadRequestException("Не найден столбец «Студенческий номер»") - } - val students = - rows.drop(headerIndex + 1).mapNotNull { row -> - fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim() - val studentNo = normalizeNumber(cell("studentNo")) - val lastName = cell("lastName") - if (studentNo.isBlank() && lastName.isBlank()) { - return@mapNotNull null - } - if (studentNo.isBlank()) { - throw BadRequestException("В одной из строк отсутствует студенческий номер") - } - ParsedStudent( - studentNo = studentNo, - lastName = lastName, - firstName = cell("firstName"), - middleName = cell("middleName"), - basis = cell("basis"), - note = cell("note"), - ) - } + val headerIndex = requireHeader(rows) + val columns = requireColumns(rows[headerIndex]) + val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) } if (students.isEmpty()) { throw BadRequestException("В таблице не найдено ни одного студента") } return ParsedGroup(groupNameFrom(filename, rows), students) } + private fun requireHeader(rows: List>): Int { + val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } } + if (headerIndex < 0) { + throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.") + } + return headerIndex + } + + private fun requireColumns(header: List): Map { + val columns = mapColumns(header) + if (columns["studentNo"] == null) { + throw BadRequestException("Не найден столбец «Студенческий номер»") + } + return columns + } + + private fun parseStudent( + row: List, + columns: Map, + ): ParsedStudent? { + fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim() + val studentNo = normalizeNumber(cell("studentNo")) + val lastName = cell("lastName") + if (studentNo.isBlank() && lastName.isBlank()) return null + if (studentNo.isBlank()) { + throw BadRequestException("В одной из строк отсутствует студенческий номер") + } + return ParsedStudent( + studentNo = studentNo, + lastName = lastName, + firstName = cell("firstName"), + middleName = cell("middleName"), + basis = cell("basis"), + note = cell("note"), + ) + } + private fun isStudentHeader(value: String): Boolean { val normalized = value.lowercase().replace("\u00a0", " ").trim() return normalized.startsWith("студен") || diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt index 9116c08..02890a3 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt @@ -114,16 +114,22 @@ class DefenseService( code: String, ): DefenseDto { syncTimeouts() - val s = - sessions.findByCode(code.trim()) - ?: throw BadRequestException("Код не найден") - if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту") - if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата") + val s = requireWaitingSession(studentId, code) s.status = DefenseStatus.started.name s.startedAt = Instant.now() return toDto(sessions.save(s)) } + private fun requireWaitingSession( + studentId: UUID, + code: String, + ): DefenseSession { + val s = sessions.findByCode(code.trim()) ?: throw BadRequestException("Код не найден") + if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту") + if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата") + return s + } + @Transactional fun finish( studentId: UUID, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt index 545f87d..d0bb707 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -32,6 +32,21 @@ class LearnController( private fun isAdmin(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true + private fun treeContainsArticle( + tree: ContentTreeNodeDto, + chapter: String, + section: String, + article: String, + ): Boolean { + val sec = + tree.chapters + .firstOrNull { it.slug == chapter } + ?.sections + ?.firstOrNull { it.slug == section } + ?: return false + return sec.articles.any { it.slug == article } + } + private fun sectionChoices(tree: ContentTreeNodeDto): List> = tree.chapters.flatMap { chapter -> chapter.sections.map { section -> @@ -103,9 +118,7 @@ class LearnController( ): String { val admin = isAdmin(session) val tree: ContentTreeNodeDto = api.contentTree(token(session), all = admin) - val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" - val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn" - if (sec.articles.none { it.slug == article }) return "redirect:/learn" + if (!treeContainsArticle(tree, chapter, section, article)) return "redirect:/learn" val a: ContentArticleDto = try {