Compare commits

...

17 Commits

Author SHA1 Message Date
debf157194 Refactor: bind admin forms via @ModelAttribute DTOs; detekt fully default 2026-09-27 23:13:29 +03:00
6129055962 Refactor: extract helpers from groupDetail/editLab; detekt thresholds to defaults 2026-09-27 22:58:35 +03:00
4035bc5a0f Refactor: split ContentService and ProgressService; defaults tightened 2026-09-27 22:48:13 +03:00
290b2a5a20 Refactor: split content order API; near-default detekt thresholds 2026-09-27 22:38:48 +03:00
3ed48b35fc Refactor: split AdminController into AdminSupport and domain controllers 2026-09-27 22:36:41 +03:00
f08ab16af0 Refactor: move defense API methods into separate extension file 2026-09-27 22:31:06 +03:00
c22eb15d3e Refactor: split ApiClient into domain extension files 2026-09-27 22:29:57 +03:00
6e891e4326 Test: Testcontainers integration tests for distribution and content import 2026-09-27 22:22:54 +03:00
47ec9884a3 Add ContentImporter tests; drop redundant elvis; document lint/test commands 2026-09-27 22:17:59 +03:00
a87110580b Lint: strict mode on; tune detekt thresholds; more decompositions 2026-09-27 22:15:00 +03:00
4a8a10971c Refactor: bind content admin forms via @ModelAttribute DTOs 2026-09-27 22:06:09 +03:00
9a2a4023b5 Refactor: split loop/throws helpers; drop deprecated JSON accessors 2026-09-27 22:02:56 +03:00
511df7977e Refactor: extract helpers to satisfy detekt (throws, returns, loops) 2026-09-27 22:00:15 +03:00
b7d96d2ea4 Refactor: reduce detekt findings (unused params, throws, long methods, config) 2026-09-27 21:54:00 +03:00
f3e8b2668f Build: enable ktlint+detekt, format sources, add core unit tests 2026-09-27 21:46:05 +03:00
802616918f Admin: name submission archives as group-studentNo-name-attempt 2026-09-27 20:46:17 +03:00
a8785d48d9 Submissions: reject XML project files, show submitted file names to admin 2026-09-24 11:32:19 +03:00
132 changed files with 6743 additions and 3716 deletions

14
.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{kt,kts}]
max_line_length = 140
# Значения enum используются как строковые статусы в API/БД — оставляем lowerCamelCase.
ktlint_standard_enum-entry-name-case = disabled

View File

@@ -48,6 +48,20 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`, Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
## Качество кода и тесты
```bash
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
./gradlew ktlintFormat # автоформат Kotlin
```
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
Оба подключены во всех Kotlin-модулях конвенцией `lms.lint` и **ломают сборку** при замечаниях.
- Конфигурация: `.editorconfig`, `config/detekt/detekt.yml`.
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
не работает на JDK 25.
- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`).
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage

View File

@@ -13,4 +13,6 @@ dependencies {
implementation(libs.kotlinAllopenPlugin) implementation(libs.kotlinAllopenPlugin)
implementation(libs.kotlinNoargPlugin) implementation(libs.kotlinNoargPlugin)
implementation(libs.springBootGradlePlugin) implementation(libs.springBootGradlePlugin)
implementation(libs.ktlintGradlePlugin)
implementation(libs.detektGradlePlugin)
} }

View File

@@ -4,6 +4,7 @@ dependencyResolutionManagement {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
repositories { repositories {
mavenCentral() mavenCentral()
gradlePluginPortal()
} }
// Reuse the version catalog from the main build. // Reuse the version catalog from the main build.

View File

@@ -0,0 +1,34 @@
// Единая конфигурация статического анализа Kotlin: ktlint (формат) + detekt (качество).
// На время миграции ignoreFailures=true — задачи только отчитываются, не ломая сборку.
import io.gitlab.arturbosch.detekt.Detekt
plugins {
id("org.jlleitschuh.gradle.ktlint")
id("io.gitlab.arturbosch.detekt")
}
ktlint {
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
detekt {
buildUponDefaultConfig = true
config.setFrom(rootProject.files("config/detekt/detekt.yml"))
ignoreFailures = false
parallel = true
}
tasks.withType<Detekt>().configureEach {
jvmTarget = "21"
exclude("**/build/**")
reports {
html.required.set(true)
xml.required.set(false)
txt.required.set(false)
sarif.required.set(false)
md.required.set(false)
}
}

View File

@@ -6,6 +6,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.spring") id("org.jetbrains.kotlin.plugin.spring")
id("org.jetbrains.kotlin.plugin.jpa") id("org.jetbrains.kotlin.plugin.jpa")
id("org.springframework.boot") id("org.springframework.boot")
id("lms.lint")
} }
group = "ru.ysu.lms" group = "ru.ysu.lms"

View File

@@ -4,6 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent
plugins { plugins {
kotlin("jvm") kotlin("jvm")
id("org.jetbrains.kotlin.plugin.spring") id("org.jetbrains.kotlin.plugin.spring")
id("lms.lint")
} }
group = "ru.ysu.lms" group = "ru.ysu.lms"

71
config/detekt/detekt.yml Normal file
View File

@@ -0,0 +1,71 @@
# Конфигурация detekt для LMS. Значения подобраны под текущий стиль кода:
# базовые правила включены (buildUponDefaultConfig = true), шумные отключены или ослаблены.
build:
maxIssues: 0
weights: {}
config:
validation: true
warningsAsErrors: false
complexity:
# Все правила сложности — строго на значениях detekt по умолчанию. Крупные агрегаты
# раздроблены: ApiClient — по доменам, AdminController — AdminSupport + 5 контроллеров,
# импорт — ContentImporter, авторинг/порядок и оценки — отдельные сервисы, submissions —
# отдельный контроллер; MVC-формы связаны через @ModelAttribute-DTO.
LongMethod:
threshold: 60
LongParameterList:
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: true
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 20
LargeClass:
threshold: 600
ComplexCondition:
threshold: 4
CyclomaticComplexMethod:
threshold: 15
style:
MagicNumber:
active: false
MaxLineLength:
active: false
ReturnCount:
max: 4
WildcardImport:
active: true
UnusedPrivateMember:
active: true
LoopWithTooManyJumpStatements:
maxJumpCount: 2
# MVC-контроллеры возвращают имя шаблона — это не «магическая константа».
FunctionOnlyReturningConstant:
ignoreAnnotated:
- 'GetMapping'
- 'PostMapping'
performance:
# runApplication<App>(*args) — идиоматичная точка входа Spring Boot.
SpreadOperator:
active: false
naming:
FunctionNaming:
ignoreAnnotated:
- 'Test'
MatchingDeclarationName:
active: false
# Enum-значения хранятся в БД и API строками в lowerCamelCase.
EnumNaming:
active: false
empty-blocks:
EmptyCatchBlock:
allowedExceptionNameRegex: 'ignored|expected|_'

View File

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/faa59212208d027d066d1bbe337c5da5/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/adf5b89d64773eff73f900e61dd2d632/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e0e37f85a97c5f2df685b37b14a25072/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ccc3884e65cd85bb71a09d2223c77229/redirect
toolchainVersion=21

View File

@@ -10,6 +10,9 @@ springdoc = "3.0.3"
flexmark = "0.64.8" flexmark = "0.64.8"
owaspSanitizer = "20260313.1" owaspSanitizer = "20260313.1"
apachePoi = "5.5.1" apachePoi = "5.5.1"
ktlint = "14.2.0"
detekt = "1.23.8"
mockk = "1.14.11"
[libraries] [libraries]
# --- Gradle plugins used from buildSrc --- # --- Gradle plugins used from buildSrc ---
@@ -17,6 +20,8 @@ kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ver
kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" } kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" }
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" } kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" } springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" }
ktlintGradlePlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" }
detektGradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
# --- Platform BOMs (versions only) --- # --- Platform BOMs (versions only) ---
springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" } springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }
@@ -65,6 +70,8 @@ owaspHtmlSanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-
apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" } apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" }
# --- Tests / Testcontainers (versions managed by the Boot BOM) --- # --- Tests / Testcontainers (versions managed by the Boot BOM) ---
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" } testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" } testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
@@ -78,3 +85,5 @@ kotlinPluginJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" } kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" } springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" }
ktlintPlugin = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detektPlugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

View File

@@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration @AutoConfiguration
class LmsCommonAutoConfiguration { class LmsCommonAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler() fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()

View File

@@ -13,17 +13,27 @@ open class LmsException(
val violations: List<FieldViolation> = emptyList(), val violations: List<FieldViolation> = emptyList(),
) : RuntimeException(message) ) : RuntimeException(message)
class BadRequestException(message: String, code: String? = null) : class BadRequestException(
LmsException(HttpStatus.BAD_REQUEST, message, code) message: String,
code: String? = null,
) : LmsException(HttpStatus.BAD_REQUEST, message, code)
class NotFoundException(message: String, code: String? = null) : class NotFoundException(
LmsException(HttpStatus.NOT_FOUND, message, code) message: String,
code: String? = null,
) : LmsException(HttpStatus.NOT_FOUND, message, code)
class ConflictException(message: String, code: String? = null) : class ConflictException(
LmsException(HttpStatus.CONFLICT, message, code) message: String,
code: String? = null,
) : LmsException(HttpStatus.CONFLICT, message, code)
class ForbiddenException(message: String, code: String? = null) : class ForbiddenException(
LmsException(HttpStatus.FORBIDDEN, message, code) message: String,
code: String? = null,
) : LmsException(HttpStatus.FORBIDDEN, message, code)
class UnauthorizedException(message: String, code: String? = null) : class UnauthorizedException(
LmsException(HttpStatus.UNAUTHORIZED, message, code) message: String,
code: String? = null,
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)

View File

@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice @RestControllerAdvice
class LmsExceptionHandler { class LmsExceptionHandler {
@ExceptionHandler(LmsException::class) @ExceptionHandler(LmsException::class)
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> = fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
ResponseEntity.status(ex.status).body( ResponseEntity.status(ex.status).body(
@@ -22,9 +21,10 @@ class LmsExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class) @ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> { fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
val violations = ex.bindingResult.fieldErrors.map { val violations =
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value") ex.bindingResult.fieldErrors.map {
} FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
return ResponseEntity.badRequest().body( return ResponseEntity.badRequest().body(
ApiError( ApiError(
title = "Validation failed", title = "Validation failed",

View File

@@ -9,4 +9,6 @@ dependencies {
api(libs.owaspHtmlSanitizer) api(libs.owaspHtmlSanitizer)
api(libs.springContext) api(libs.springContext)
api(libs.springBootAutoconfigure) api(libs.springBootAutoconfigure)
testImplementation(project(":libs:test-support"))
} }

View File

@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration @AutoConfiguration
@EnableConfigurationProperties(MarkdownProperties::class) @EnableConfigurationProperties(MarkdownProperties::class)
class LmsMarkdownAutoConfiguration { class LmsMarkdownAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
MarkdownRenderer(allowHtml = properties.allowHtml)
} }

View File

@@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory
class MarkdownRenderer( class MarkdownRenderer(
private val allowHtml: Boolean = false, private val allowHtml: Boolean = false,
) { ) {
private val options = MutableDataSet().apply { private val options =
set( MutableDataSet().apply {
Parser.EXTENSIONS, set(
listOf( Parser.EXTENSIONS,
TablesExtension.create(), listOf(
StrikethroughExtension.create(), TablesExtension.create(),
TaskListExtension.create(), StrikethroughExtension.create(),
FootnoteExtension.create(), TaskListExtension.create(),
AutolinkExtension.create(), FootnoteExtension.create(),
), AutolinkExtension.create(),
) ),
set(HtmlRenderer.GENERATE_HEADER_ID, true) )
set(HtmlRenderer.ESCAPE_HTML, !allowHtml) set(HtmlRenderer.GENERATE_HEADER_ID, true)
} set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val parser: Parser = Parser.builder(options).build() private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build() private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
@@ -55,21 +56,26 @@ class MarkdownRenderer(
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */ /** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
private fun wrapTables(html: String): String = private fun wrapTables(html: String): String =
html.replace("<table", "<div class=\"table-scroll\"><table") html
.replace("<table", "<div class=\"table-scroll\"><table")
.replace("</table>", "</table></div>") .replace("</table>", "</table></div>")
private fun renderRaw(markdown: String, depth: Int): String { private fun renderRaw(
markdown: String,
depth: Int,
): String {
val out = StringBuilder() val out = StringBuilder()
var cursor = 0 var cursor = 0
for (match in SPOILER.findAll(markdown)) { for (match in SPOILER.findAll(markdown)) {
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first))) out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
val title = match.groupValues[1].trim().ifBlank { "Спойлер" } val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
val body = match.groupValues[2] val body = match.groupValues[2]
val inner = if (depth < MAX_DEPTH) { val inner =
renderRaw(body, depth + 1) if (depth < MAX_DEPTH) {
} else { renderRaw(body, depth + 1)
renderWithDirectives(body) } else {
} renderWithDirectives(body)
}
out.append("<details class=\"spoiler\"><summary>") out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title)) out.append(escapeHtml(title))
out.append("</summary><div class=\"spoiler-body\">") out.append("</summary><div class=\"spoiler-body\">")
@@ -81,8 +87,7 @@ class MarkdownRenderer(
return out.toString() return out.toString()
} }
private fun renderSegment(markdown: String): String = private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
renderer.render(parser.parse(markdown))
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */ /** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
private fun renderWithDirectives(markdown: String): String { private fun renderWithDirectives(markdown: String): String {
@@ -118,51 +123,114 @@ class MarkdownRenderer(
return out.toString() return out.toString()
} }
private fun renderInline(text: String): String = private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun escapeHtml(value: String): String = value private fun escapeHtml(value: String): String =
.replace("&", "&amp;") value
.replace("<", "&lt;") .replace("&", "&amp;")
.replace(">", "&gt;") .replace("<", "&lt;")
.replace("\"", "&quot;") .replace(">", "&gt;")
.replace("\"", "&quot;")
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder() private fun buildSanitizer(): PolicyFactory =
.allowElements( HtmlPolicyBuilder()
"p", "br", "hr", .allowElements(*ALLOWED_ELEMENTS)
"h1", "h2", "h3", "h4", "h5", "h6", .allowAttributes("href")
"strong", "b", "em", "i", "del", "s", "blockquote", .onElements("a")
"ul", "ol", "li", .allowAttributes("title")
"a", "img", .onElements("a", "img")
"code", "pre", .allowAttributes("src", "alt")
"table", "thead", "tbody", "tr", "th", "td", .onElements("img")
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption", .allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
"video", "source", "track", "audio", .onElements("video")
) .allowAttributes("src", "type")
.allowAttributes("href").onElements("a") .onElements("source")
.allowAttributes("title").onElements("a", "img") .allowAttributes("src", "kind", "label", "srclang", "default")
.allowAttributes("src", "alt").onElements("img") .onElements("track")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline") .allowAttributes("class")
.onElements("video") .onElements(*CLASS_ATTR_ELEMENTS)
.allowAttributes("src", "type").onElements("source") .allowAttributes("id")
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track") .onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("class").onElements( .allowAttributes("colspan", "rowspan")
"code", "pre", "div", "span", "details", "summary", "table", "th", "td", .onElements("td", "th")
"figure", "figcaption", "video", "img", .allowUrlProtocols("http", "https", "mailto")
) .requireRelNofollowOnLinks()
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6") .toFactory()
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object { private companion object {
const val MAX_DEPTH = 5 const val MAX_DEPTH = 5
val MEDIA = Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$", /** Теги, разрешённые в отрендеренном HTML (allow-list санитайзера). */
) val ALLOWED_ELEMENTS =
val SPOILER = Regex( arrayOf(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$", "p",
) "br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"strong",
"b",
"em",
"i",
"del",
"s",
"blockquote",
"ul",
"ol",
"li",
"a",
"img",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"details",
"summary",
"div",
"span",
"sup",
"sub",
"kbd",
"figure",
"figcaption",
"video",
"source",
"track",
"audio",
)
/** Элементы, которым разрешён атрибут class (подсветка кода, фигуры и т.п.). */
val CLASS_ATTR_ELEMENTS =
arrayOf(
"code",
"pre",
"div",
"span",
"details",
"summary",
"table",
"th",
"td",
"figure",
"figcaption",
"video",
"img",
)
val MEDIA =
Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER =
Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
} }
} }

View File

@@ -0,0 +1,61 @@
package ru.ysu.lms.markdown
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class MarkdownRendererTest {
private val renderer = MarkdownRenderer()
@Test
fun `renders basic markdown`() {
val html = renderer.render("# Заголовок\n\n**жирный** и *курсив*")
assertThat(html).contains("<h1")
assertThat(html).contains("Заголовок")
assertThat(html).contains("<strong>жирный</strong>")
assertThat(html).contains("<em>курсив</em>")
}
@Test
fun `renders lists and code blocks`() {
val html = renderer.render("- один\n- два\n\n```c\nint x = 1;\n```")
assertThat(html).contains("<ul>")
assertThat(html).contains("<li>")
assertThat(html).contains("один")
assertThat(html).contains("<pre>")
assertThat(html).contains("language-c")
assertThat(html).contains("int x")
}
@Test
fun `wraps tables into scrollable container`() {
val md =
"""
| № | Задание |
|---|---|
| 1 | Сумма |
""".trimIndent()
val html = renderer.render(md)
assertThat(html).contains("<div class=\"table-scroll\"><table")
assertThat(html).contains("</table></div>")
}
@Test
fun `escapes raw html and strips scripts`() {
val html = renderer.render("<script>alert(1)</script>")
assertThat(html).doesNotContain("<script>")
assertThat(html).contains("&lt;script&gt;")
}
@Test
fun `renders spoiler container`() {
val html = renderer.render(":::spoiler Ответ\nскрытый текст\n:::")
assertThat(html).contains("spoiler")
assertThat(html).contains("скрытый текст")
}
}

View File

@@ -2,7 +2,8 @@ package ru.ysu.lms.security
enum class LmsRole { enum class LmsRole {
ADMIN, ADMIN,
STUDENT; STUDENT,
;
fun authority(): String = "ROLE_$name" fun authority(): String = "ROLE_$name"
} }

View File

@@ -19,14 +19,15 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
@AutoConfiguration @AutoConfiguration
@EnableConfigurationProperties(JwtProperties::class) @EnableConfigurationProperties(JwtProperties::class)
class LmsSecurityAutoConfiguration { class LmsSecurityAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"]) @ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
fun jwtEncoder(properties: JwtProperties): JwtEncoder { fun jwtEncoder(properties: JwtProperties): JwtEncoder {
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey)) val rsaKey =
.privateKey(RsaKeys.privateKey(properties.privateKey)) RSAKey
.build() .Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey))) return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
} }

View File

@@ -9,24 +9,26 @@ import java.util.Base64
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */ /** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
object RsaKeys { object RsaKeys {
fun publicKey(pem: String): RSAPublicKey { fun publicKey(pem: String): RSAPublicKey {
val der = decode(pem) val der = decode(pem)
return KeyFactory.getInstance("RSA") return KeyFactory
.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey .generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
} }
fun privateKey(pem: String): RSAPrivateKey { fun privateKey(pem: String): RSAPrivateKey {
val der = decode(pem) val der = decode(pem)
return KeyFactory.getInstance("RSA") return KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey .generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
} }
private fun decode(pem: String): ByteArray { private fun decode(pem: String): ByteArray {
val body = pem val body =
.replace(Regex("-----BEGIN [A-Z ]+-----"), "") pem
.replace(Regex("-----END [A-Z ]+-----"), "") .replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("\\s"), "") .replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
return Base64.getDecoder().decode(body) return Base64.getDecoder().decode(body)
} }
} }

View File

@@ -3,11 +3,14 @@ plugins {
} }
dependencies { dependencies {
implementation(platform(libs.springBootBom)) api(platform(libs.springBootBom))
// Shared integration-test infrastructure. Consumers add this module as testImplementation. // Shared integration-test infrastructure. Consumers add this module as testImplementation.
api(libs.springBootTestcontainers) api(libs.springBootTestcontainers)
api(libs.testcontainersPostgresql) api(libs.testcontainersPostgresql)
api(libs.testcontainersJunitJupiter) api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest) api(libs.springBootStarterTest)
api(libs.mockk)
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
api(libs.junitPlatformLauncher)
} }

View File

@@ -8,30 +8,29 @@ import java.nio.file.Path
import java.util.UUID import java.util.UUID
@Component @Component
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) { class AssetStorage(
@Value("\${lms.content.assets-dir:/data/content-assets}") root: String,
) {
private val rootDir: Path = Path.of(root) private val rootDir: Path = Path.of(root)
init { init {
Files.createDirectories(rootDir) Files.createDirectories(rootDir)
} }
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String) data class StoredAsset(
val publicName: String,
val storedPath: Path,
val mime: String,
)
fun store(originalName: String, bytes: ByteArray): StoredAsset { fun store(
originalName: String,
bytes: ByteArray,
): StoredAsset {
val ext = originalName.substringAfterLast('.', "").lowercase() val ext = originalName.substringAfterLast('.', "").lowercase()
val isVideo = ext in VIDEO_EXTENSIONS val isVideo = ext in VIDEO_EXTENSIONS
val isImage = ext in IMAGE_EXTENSIONS requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS)
if (!isVideo && !isImage) { requireSizeWithinLimit(originalName, bytes.size, isVideo)
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} МБ)")
}
val publicName = "${UUID.randomUUID()}.$ext" val publicName = "${UUID.randomUUID()}.$ext"
val target = rootDir.resolve(publicName).normalize() val target = rootDir.resolve(publicName).normalize()
if (!target.startsWith(rootDir)) { if (!target.startsWith(rootDir)) {
@@ -41,6 +40,31 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return StoredAsset(publicName, target, mimeFor(ext)) 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<Path, String>? { fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/') val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize() val target = rootDir.resolve(clean).normalize()
@@ -50,22 +74,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return target to mimeFor(ext) return target to mimeFor(ext)
} }
private fun mimeFor(ext: String): String = when (ext) { private fun mimeFor(ext: String): String =
"png" -> "image/png" when (ext) {
"jpg", "jpeg" -> "image/jpeg" "png" -> "image/png"
"gif" -> "image/gif" "jpg", "jpeg" -> "image/jpeg"
"svg" -> "image/svg+xml" "gif" -> "image/gif"
"webp" -> "image/webp" "svg" -> "image/svg+xml"
"mp4" -> "video/mp4" "webp" -> "image/webp"
"webm" -> "video/webm" "mp4" -> "video/mp4"
"ogg", "ogv" -> "video/ogg" "webm" -> "video/webm"
else -> "application/octet-stream" "ogg", "ogv" -> "video/ogg"
} else -> "application/octet-stream"
}
private companion object { private companion object {
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp") val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv") val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
} }
} }

View File

@@ -0,0 +1,246 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
data class ArticlePlacement(
val title: String,
val bodyMd: String,
val chapterId: UUID?,
val newChapterTitle: String?,
val sectionId: UUID?,
val newSectionTitle: String?,
)
@Service
class ContentAuthoringService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
) {
@Transactional
fun updateArticle(
articleId: UUID,
title: String,
bodyMd: String,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim()
a.bodyMd = bodyMd
articles.save(a)
}
@Transactional
fun deleteArticle(articleId: UUID) {
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
articles.deleteById(articleId)
}
@Transactional
fun createChapter(
title: String,
slug: String?,
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode(
id = ch.id.toString(),
title = ch.title,
slug = ch.slug,
position = ch.position,
published = ch.published,
sections = emptyList(),
)
}
@Transactional
fun renameChapter(
chapterId: UUID,
title: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim()
chapters.save(ch)
}
@Transactional
fun deleteChapter(chapterId: UUID) {
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
chapters.deleteById(chapterId)
}
@Transactional
fun createSection(
chapterId: UUID,
title: String,
slug: String?,
): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec =
sections.save(
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
)
return SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles = emptyList(),
)
}
@Transactional
fun renameSection(
sectionId: UUID,
title: String,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim()
sections.save(sec)
}
@Transactional
fun deleteSection(sectionId: UUID) {
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
sections.deleteById(sectionId)
}
/** Переносит все статьи раздела в другой и удаляет раздел. */
@Transactional
fun moveArticlesAndDeleteSection(
sectionId: UUID,
targetSectionId: UUID,
) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
for (a in moving) {
pos += 1
a.sectionId = dst.id
a.position = pos
articles.save(a)
}
sections.delete(src)
}
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
@Transactional
fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView {
val targetSection: ContentSection =
when {
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
val anchor =
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
placement.sectionId != null ->
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!placement.newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter =
when {
placement.chapterId != null ->
chapters
.findById(placement.chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!placement.newChapterTitle.isNullOrBlank() -> {
val node = createChapter(placement.newChapterTitle, null)
chapters
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
}
val node = createSection(chapter.id, placement.newSectionTitle, null)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, placement.title, placement.bodyMd)
}
@Transactional
fun createArticle(
sectionId: UUID,
title: String,
bodyMd: String,
): ContentArticleView {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a =
articles.save(
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
)
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
@Transactional
fun moveArticle(
articleId: UUID,
newSectionId: UUID,
): ContentArticleView {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
a.sectionId = sec.id
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
articles.save(a)
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
}

View File

@@ -0,0 +1,288 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
/**
* Импорт материалов из zip со структурой каталогов:
* `chapters/01-глава/01-раздел/01-статья.md` (front-matter: `title`, `slug`),
* картинки — в `assets/`.
*
* Главы, разделы и статьи сопоставляются по названию (запасной вариант — по slug),
* поэтому повторный импорт не плодит дубли и не затирает материалы, созданные вручную.
*/
@Service
class ContentImporter(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
) {
@Transactional
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
requireZip(filename)
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val contentFiles = files.filterKeys(::isContentPath)
if (contentFiles.isEmpty()) {
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
}
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
val parsed = parseArticles(contentFiles)
return mergeIntoDb(parsed, linkRewrite, sync)
}
private fun requireZip(filename: String) {
if (!filename.lowercase().endsWith(".zip")) throw BadRequestException("Ожидается .zip архив")
}
private fun mergeIntoDb(
parsed: List<ParsedArticle>,
linkRewrite: Map<String, String>,
sync: Boolean,
): ContentImportResult {
val state = ImportState()
for (p in parsed) {
val chapter = chapterFor(p, state)
val section = sectionFor(chapter, p, state)
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
upsertArticle(section, p, body, state)
}
val removed = if (sync) removeMissing(state) else 0
return ContentImportResult(
chapters = state.chapters,
sections = state.sections,
articles = state.articles,
assets = linkRewrite.size,
removed = removed,
)
}
private fun chapterFor(
p: ParsedArticle,
state: ImportState,
): ContentChapter {
if (state.chapter?.title != p.chapterDir) {
val slug = Slugs.slugify(p.chapterDir) ?: "chapter-${state.chapters + 1}"
val existing = chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug)
val entity =
existing?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: ContentChapter(
title = p.chapterDir,
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
)
state.chapter = chapters.save(entity)
state.touchedChapters.add(state.chapter!!.id)
state.chapters += 1
state.section = null
}
return state.chapter ?: throw BadRequestException("Не удалось определить главу")
}
private fun sectionFor(
chapter: ContentChapter,
p: ParsedArticle,
state: ImportState,
): ContentSection {
if (state.section?.title != p.sectionDir) {
val slug = Slugs.slugify(p.sectionDir) ?: "section-${state.sections + 1}"
val existing =
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
val entity =
existing?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug =
Slugs.uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos,
)
state.section = sections.save(entity)
state.seenSections.add(state.section!!.id)
state.sections += 1
}
return state.section ?: throw BadRequestException("Не удалось определить раздел")
}
private fun upsertArticle(
section: ContentSection,
p: ParsedArticle,
body: String,
state: ImportState,
) {
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
val existing =
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article =
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
state.seenArticles.add(article.id)
state.articles += 1
}
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
private fun removeMissing(state: ImportState): Int =
state.touchedChapters.sumOf { chapterId ->
sections
.findByChapterIdOrderByPositionAscIdAsc(chapterId)
.sumOf { section -> removeMissingInSection(section, state) }
}
private fun removeMissingInSection(
section: ContentSection,
state: ImportState,
): Int {
if (section.id !in state.seenSections) {
val removedCount = articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
return removedCount
}
var removed = 0
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in state.seenArticles) {
articles.delete(article)
removed++
}
}
return removed
}
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
return linkRewrite
}
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
private fun isContentPath(path: String): Boolean = (path.contains("/chapters/") || path.startsWith("chapters/")) && path.endsWith(".md")
private fun parseArticles(contentFiles: Map<String, ByteArray>): List<ParsedArticle> =
contentFiles
.map { (path, data) -> parseArticle(path, data) }
.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
private fun parseArticle(
path: String,
data: ByteArray,
): ParsedArticle {
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException(
"Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)",
)
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val (frontMatter, body) = splitFrontMatter(String(data, Charsets.UTF_8))
return ParsedArticle(
chapterPos = Slugs.positionOf(chapterDir),
chapterDir = Slugs.titleFromDir(chapterDir),
sectionPos = Slugs.positionOf(sectionDir),
sectionDir = Slugs.titleFromDir(sectionDir),
articlePos = Slugs.positionOf(fileName),
fileTitle = frontMatter["title"] ?: Slugs.titleFromDir(fileName),
fmSlug = frontMatter["slug"],
body = body,
)
}
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end =
lines.withIndex().firstOrNull { it.index != 0 && it.value.trim() == "---" }?.index
?: return emptyMap<String, String>() to content
val frontMatter = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) frontMatter[parts[0].trim()] = unquote(parts[1].trim())
}
return frontMatter to lines.drop(end + 1).joinToString("\n").trim()
}
private fun unquote(value: String): String =
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
value.substring(1, value.length - 1)
} else {
value
}
private data class ParsedArticle(
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
)
private class ImportState {
var chapter: ContentChapter? = null
var section: ContentSection? = null
var chapters: Int = 0
var sections: Int = 0
var articles: Int = 0
val touchedChapters: MutableSet<UUID> = mutableSetOf()
val seenSections: MutableSet<UUID> = mutableSetOf()
val seenArticles: MutableSet<UUID> = mutableSetOf()
}
}

View File

@@ -0,0 +1,88 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.util.UUID
@Service
class ContentOrderService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
) {
/** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */
@Transactional
fun reorder(tree: ReorderTree) {
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
val chapter =
chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
chapter.position = chapterIndex + 1
chapters.save(chapter)
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto ->
val section =
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
section.chapterId = chapter.id
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
}
}
}
}
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
@Transactional
fun reorderSections(
chapterId: UUID,
tree: List<ReorderSection>,
) {
tree.forEachIndexed { sectionIndex, sectionDto ->
val section =
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (section.chapterId != chapterId) return@forEachIndexed
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
}
}
}
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
@Transactional
fun reorderArticles(
sectionId: UUID,
tree: List<ReorderArticle>,
) {
tree.forEachIndexed { articleIndex, articleDto ->
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (article.sectionId != sectionId) return@forEachIndexed
article.position = articleIndex + 1
articles.save(article)
}
}
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
}

View File

@@ -2,17 +2,11 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.io.ByteArrayInputStream
import java.util.UUID import java.util.UUID
import java.util.zip.ZipInputStream
data class ArticleTreeNode( data class ArticleTreeNode(
val id: String, val id: String,
@@ -40,7 +34,9 @@ data class ChapterTreeNode(
val sections: List<SectionTreeNode>, val sections: List<SectionTreeNode>,
) )
data class ContentTree(val chapters: List<ChapterTreeNode>) data class ContentTree(
val chapters: List<ChapterTreeNode>,
)
data class ContentArticleView( data class ContentArticleView(
val id: String, val id: String,
@@ -68,27 +64,32 @@ data class ContentImportResult(
* (и перенос разделов между главами), порядок статей внутри разделов * (и перенос разделов между главами), порядок статей внутри разделов
* (и перенос статей между разделами). Применяется одним запросом. * (и перенос статей между разделами). Применяется одним запросом.
*/ */
data class ReorderArticle(val id: String) data class ReorderArticle(
data class ReorderSection(val id: String, val articles: List<ReorderArticle> = emptyList()) val id: String,
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList()) )
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
data class ReorderSection(
val id: String,
val articles: List<ReorderArticle> = emptyList(),
)
data class ReorderChapter(
val id: String,
val sections: List<ReorderSection> = emptyList(),
)
data class ReorderTree(
val chapters: List<ReorderChapter> = emptyList(),
)
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */ /** Порядок разделов внутри одной главы (drag & drop на странице главы). */
data class ReorderSections(val sections: List<ReorderSection> = emptyList()) data class ReorderSections(
val sections: List<ReorderSection> = emptyList(),
)
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ /** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList()) data class ReorderArticles(
val articles: List<ReorderArticle> = emptyList(),
private data class ParsedArticle(
val path: String,
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
) )
@Service @Service
@@ -96,18 +97,21 @@ class ContentService(
private val chapters: ContentChapterRepository, private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository, private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val assets: AssetStorage, private val importer: ContentImporter,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree { fun tree(includeHidden: Boolean = false): ContentTree {
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published } val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
val sectionList = sections.findAllByOrderByPositionAscIdAsc() val sectionList =
.filter { includeHidden || it.published } sections
.groupBy { it.chapterId } .findAllByOrderByPositionAscIdAsc()
val articleList = articles.findAllByOrderByPositionAscIdAsc() .filter { includeHidden || it.published }
.filter { includeHidden || it.published } .groupBy { it.chapterId }
.groupBy { it.sectionId } val articleList =
articles
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
return ContentTree( return ContentTree(
chapterList.map { ch -> chapterList.map { ch ->
ChapterTreeNode( ChapterTreeNode(
@@ -116,18 +120,20 @@ class ContentService(
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
sections = (sectionList[ch.id] ?: emptyList()).map { sec -> sections =
SectionTreeNode( (sectionList[ch.id] ?: emptyList()).map { sec ->
id = sec.id.toString(), SectionTreeNode(
title = sec.title, id = sec.id.toString(),
slug = sec.slug, title = sec.title,
position = sec.position, slug = sec.slug,
published = sec.published, position = sec.position,
articles = (articleList[sec.id] ?: emptyList()).map { a -> published = sec.published,
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published) articles =
}, (articleList[sec.id] ?: emptyList()).map { a ->
) ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
}, },
)
},
) )
}, },
) )
@@ -144,12 +150,12 @@ class ContentService(
articleSlug: String, articleSlug: String,
includeHidden: Boolean = false, includeHidden: Boolean = false,
): ContentArticleView { ): ContentArticleView {
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена") val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта") requireVisible(ch.published, includeHidden, "Глава скрыта")
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден") val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт") requireVisible(sec.published, includeHidden, "Раздел скрыт")
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена") val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта") requireVisible(a.published, includeHidden, "Статья скрыта")
return ContentArticleView( return ContentArticleView(
id = a.id.toString(), id = a.id.toString(),
chapterSlug = ch.slug, chapterSlug = ch.slug,
@@ -163,84 +169,44 @@ class ContentService(
) )
} }
/** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */ private fun <T> requireFound(
@Transactional value: T?,
fun reorder(tree: ReorderTree) { message: String,
tree.chapters.forEachIndexed { chapterIndex, chapterDto -> ): T = value ?: throw NotFoundException(message)
val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
chapter.position = chapterIndex + 1
chapters.save(chapter)
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto -> private fun requireVisible(
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) published: Boolean,
?: return@forEachIndexed includeHidden: Boolean,
section.chapterId = chapter.id message: String,
section.position = sectionIndex + 1 ) {
sections.save(section) if (!includeHidden && !published) throw NotFoundException(message)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
}
}
}
} }
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
@Transactional @Transactional
fun reorderSections(chapterId: UUID, tree: List<ReorderSection>) { fun setChapterPublished(
tree.forEachIndexed { sectionIndex, sectionDto -> chapterId: UUID,
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) published: Boolean,
?: return@forEachIndexed ) {
if (section.chapterId != chapterId) return@forEachIndexed
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
}
}
}
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
@Transactional
fun reorderArticles(sectionId: UUID, tree: List<ReorderArticle>) {
tree.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (article.sectionId != sectionId) return@forEachIndexed
article.position = articleIndex + 1
articles.save(article)
}
}
private fun uuidOrNull(value: String): UUID? =
runCatching { UUID.fromString(value) }.getOrNull()
@Transactional
fun setChapterPublished(chapterId: UUID, published: Boolean) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
ch.published = published ch.published = published
chapters.save(ch) chapters.save(ch)
} }
@Transactional @Transactional
fun setSectionPublished(sectionId: UUID, published: Boolean) { fun setSectionPublished(
sectionId: UUID,
published: Boolean,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
sec.published = published sec.published = published
sections.save(sec) sections.save(sec)
} }
@Transactional @Transactional
fun setArticlePublished(articleId: UUID, published: Boolean) { fun setArticlePublished(
articleId: UUID,
published: Boolean,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
a.published = published a.published = published
articles.save(a) articles.save(a)
@@ -267,400 +233,9 @@ class ContentService(
} }
@Transactional @Transactional
fun updateArticle(articleId: UUID, title: String, bodyMd: String) { fun importZip(
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } filename: String,
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") bytes: ByteArray,
a.title = title.trim() sync: Boolean = true,
a.bodyMd = bodyMd ): ContentImportResult = importer.importZip(filename, bytes, sync)
articles.save(a)
}
@Transactional
fun deleteArticle(articleId: UUID) {
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
articles.deleteById(articleId)
}
@Transactional
fun createChapter(title: String, slug: String?): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode(
id = ch.id.toString(), title = ch.title, slug = ch.slug,
position = ch.position, published = ch.published, sections = emptyList(),
)
}
@Transactional
fun renameChapter(chapterId: UUID, title: String) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim()
chapters.save(ch)
}
@Transactional
fun deleteChapter(chapterId: UUID) {
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
chapters.deleteById(chapterId)
}
@Transactional
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec = sections.save(
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
)
return SectionTreeNode(
id = sec.id.toString(), title = sec.title, slug = sec.slug,
position = sec.position, published = sec.published, articles = emptyList(),
)
}
@Transactional
fun renameSection(sectionId: UUID, title: String) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim()
sections.save(sec)
}
@Transactional
fun deleteSection(sectionId: UUID) {
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
sections.deleteById(sectionId)
}
/** Переносит все статьи раздела в другой и удаляет раздел. */
@Transactional
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
for (a in moving) {
pos += 1
a.sectionId = dst.id
a.position = pos
articles.save(a)
}
sections.delete(src)
}
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
@Transactional
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
val targetSection: ContentSection = when {
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
val anchor = sections.findById(sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, newSectionTitle, null)
sections.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
sectionId != null -> sections.findById(sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter = when {
chapterId != null -> chapters.findById(chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!newChapterTitle.isNullOrBlank() -> {
val node = createChapter(newChapterTitle, null)
chapters.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
}
val node = createSection(chapter.id, newSectionTitle, null)
sections.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, title, bodyMd)
}
@Transactional
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a = articles.save(
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
)
return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
sectionSlug = sec.slug, sectionTitle = sec.title,
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
)
}
@Transactional
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
a.sectionId = sec.id
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
articles.save(a)
return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
sectionSlug = sec.slug, sectionTitle = sec.title,
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
)
}
private fun uniqueSlug(title: String, taken: Set<String>): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/**
* Импорт zip со структурой каталогов:
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
* картинки в assets/.
*/
@Transactional
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
val contentFiles = files.filterKeys { k ->
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
}
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
val parsed = contentFiles.map { (path, data) ->
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
ParsedArticle(
path = path,
chapterPos = positionOf(chapterDir),
chapterDir = titleFromDir(chapterDir),
sectionPos = positionOf(sectionDir),
sectionDir = titleFromDir(sectionDir),
articlePos = positionOf(fileName),
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
fmSlug = parsedContent.first["slug"],
body = parsedContent.second,
)
}.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
var chCount = 0
var secCount = 0
var artCount = 0
var removedCount = 0
var currentChapter: ContentChapter? = null
var currentSection: ContentSection? = null
val touchedChapters = mutableSetOf<UUID>()
val seenSections = mutableSetOf<UUID>()
val seenArticles = mutableSetOf<UUID>()
for (p in parsed) {
if (currentChapter?.title != p.chapterDir) {
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
// Ищем по названию, затем по slug: так импорт находит главу, созданную
// вручную или импортированную ранее, и не плодит дубли.
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: chapters.save(
ContentChapter(
title = p.chapterDir,
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
),
)
currentChapter = chapters.save(currentChapter!!)
touchedChapters.add(currentChapter!!.id)
chCount += 1
currentSection = null
}
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: sections.save(
ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
position = p.sectionPos,
),
)
currentSection = sections.save(currentSection!!)
seenSections.add(currentSection!!.id)
secCount += 1
}
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
var body = p.body
for ((from, to) in linkRewrite) {
body = body.replace(from, to)
}
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
seenArticles.add(article.id)
artCount += 1
}
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
for (chapterId in if (sync) touchedChapters else emptySet()) {
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
if (section.id in seenSections) {
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in seenArticles) {
articles.delete(article)
removedCount++
}
}
} else {
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
}
}
}
return ContentImportResult(
chapters = chCount,
sections = secCount,
articles = artCount,
assets = linkRewrite.size,
removed = removedCount,
)
}
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
result[entry.name.removePrefix("/")] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end = lines.withIndex()
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
?.index ?: return emptyMap<String, String>() to content
val fm = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
val v = parts[1].trim()
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
v.substring(1, v.length - 1)
} else {
v
}
fm[parts[0].trim()] = unquoted
}
}
return fm to lines.drop(end + 1).joinToString("\n").trim()
}
private fun positionOf(name: String): Int {
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
private fun titleFromDir(dir: String): String {
val raw = dir.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
private fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug = translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
private companion object {
val TRANSLIT = mapOf(
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
)
}
} }

View File

@@ -0,0 +1,81 @@
package ru.ysu.lms.academic.content
/** Транслитерация и построение slug для глав, разделов и статей. */
object Slugs {
private val TRANSLIT =
mapOf(
"а" to "a",
"б" to "b",
"в" to "v",
"г" to "g",
"д" to "d",
"е" to "e",
"ё" to "e",
"ж" to "zh",
"з" to "z",
"и" to "i",
"й" to "y",
"к" to "k",
"л" to "l",
"м" to "m",
"н" to "n",
"о" to "o",
"п" to "p",
"р" to "r",
"с" to "s",
"т" to "t",
"у" to "u",
"ф" to "f",
"х" to "h",
"ц" to "c",
"ч" to "ch",
"ш" to "sh",
"щ" to "sch",
"ъ" to "",
"ы" to "y",
"ь" to "",
"э" to "e",
"ю" to "yu",
"я" to "ya",
)
/** «01-Ввод-вывод» -> «vvod-vyvod»; null, если не осталось допустимых символов. */
fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug =
translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
/** Уникальный slug на основе заголовка: добавляет «-2», «-3», если базовый занят. */
fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/** Числовой префикс каталога («02-Матрицы» -> 2); 999, если префикса нет. */
fun positionOf(name: String): Int {
val match = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return match?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
/** «02-Матрицы» -> «Матрицы» (без числового префикса, подчёркивания → пробелы). */
fun titleFromDir(dir: String): String {
val raw =
dir
.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
}

View File

@@ -12,20 +12,25 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.content.ArticlePlacement
import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.content.ReorderArticles
import ru.ysu.lms.academic.content.ReorderSections
import ru.ysu.lms.academic.content.ReorderTree
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/content") @RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController(private val contentService: ContentService) { class ContentAdminController(
private val contentService: ContentService,
private val authoring: ContentAuthoringService,
) {
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
data class ArticleCreatePayload( data class ArticleCreatePayload(
val chapterId: String? = null, val chapterId: String? = null,
val sectionId: String? = null, val sectionId: String? = null,
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
val title: String, val title: String,
val bodyMd: String = "", val bodyMd: String = "",
) )
data class ArticleMovePayload(val sectionId: String)
data class TitlePayload(val title: String) data class ArticleMovePayload(
data class PublishedPayload(val published: Boolean) val sectionId: String,
data class ChapterCreatePayload(val title: String, val slug: String? = null) )
data class SectionCreatePayload(val title: String, val slug: String? = null)
data class TitlePayload(
val title: String,
)
data class PublishedPayload(
val published: Boolean,
)
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
/** Полное дерево материалов, включая скрытые (для админки). */ /** Полное дерево материалов, включая скрытые (для админки). */
@GetMapping("/tree/all") @GetMapping("/tree/all")
@@ -53,45 +75,42 @@ class ContentAdminController(private val contentService: ContentService) {
): ContentPublicController.ContentArticleViewDto { ): ContentPublicController.ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article, includeHidden = true) val a = contentService.findArticle(chapter, section, article, includeHidden = true)
return ContentPublicController.ContentArticleViewDto( return ContentPublicController.ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle, id = a.id,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle, chapterSlug = a.chapterSlug,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
) )
} }
@PutMapping("/chapters/{id}/published") @PutMapping("/chapters/{id}/published")
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) { fun chapterPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setChapterPublished(id, body.published) contentService.setChapterPublished(id, body.published)
} }
@PutMapping("/sections/{id}/published") @PutMapping("/sections/{id}/published")
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) { fun sectionPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setSectionPublished(id, body.published) contentService.setSectionPublished(id, body.published)
} }
@PutMapping("/articles/{id}/published") @PutMapping("/articles/{id}/published")
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) { fun articlePublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setArticlePublished(id, body.published) contentService.setArticlePublished(id, body.published)
} }
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
@PostMapping("/reorder")
fun reorder(@RequestBody body: ReorderTree) {
contentService.reorder(body)
}
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/sections/reorder")
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) {
contentService.reorderSections(id, body.sections)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/articles/reorder")
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) {
contentService.reorderArticles(id, body.articles)
}
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@@ -105,25 +124,35 @@ class ContentAdminController(private val contentService: ContentService) {
fun list() = contentService.listAll() fun list() = contentService.listAll()
@PostMapping("/chapters") @PostMapping("/chapters")
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug) fun createChapter(
@RequestBody body: ChapterCreatePayload,
) = authoring.createChapter(body.title, body.slug)
@PutMapping("/chapters/{id}") @PutMapping("/chapters/{id}")
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) = fun renameChapter(
contentService.renameChapter(id, body.title) @PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = authoring.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}") @DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> { fun deleteChapter(
contentService.deleteChapter(id) @PathVariable id: UUID,
): ResponseEntity<Void> {
authoring.deleteChapter(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@PostMapping("/sections") @PostMapping("/sections")
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) = fun createSection(
contentService.createSection(chapterId, body.title, body.slug) @RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload,
) = authoring.createSection(chapterId, body.title, body.slug)
@PutMapping("/sections/{id}") @PutMapping("/sections/{id}")
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) = fun renameSection(
contentService.renameSection(id, body.title) @PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = authoring.renameSection(id, body.title)
@DeleteMapping("/sections/{id}") @DeleteMapping("/sections/{id}")
fun deleteSection( fun deleteSection(
@@ -131,35 +160,46 @@ class ContentAdminController(private val contentService: ContentService) {
@RequestParam(required = false) moveTo: UUID?, @RequestParam(required = false) moveTo: UUID?,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
if (moveTo != null) { if (moveTo != null) {
contentService.moveArticlesAndDeleteSection(id, moveTo) authoring.moveArticlesAndDeleteSection(id, moveTo)
} else { } else {
contentService.deleteSection(id) authoring.deleteSection(id)
} }
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@PostMapping("/articles") @PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced( fun createArticle(
title = body.title, @RequestBody body: ArticleCreatePayload,
bodyMd = body.bodyMd, ) = authoring.createArticlePlaced(
chapterId = body.chapterId?.let { UUID.fromString(it) }, ArticlePlacement(
newChapterTitle = body.newChapterTitle, title = body.title,
sectionId = body.sectionId?.let { UUID.fromString(it) }, bodyMd = body.bodyMd,
newSectionTitle = body.newSectionTitle, chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
),
) )
@PutMapping("/articles/{id}") @PutMapping("/articles/{id}")
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) { fun update(
contentService.updateArticle(id, body.title, body.bodyMd) @PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
) {
authoring.updateArticle(id, body.title, body.bodyMd)
} }
@PostMapping("/articles/{id}/move") @PostMapping("/articles/{id}/move")
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) = fun move(
contentService.moveArticle(id, UUID.fromString(body.sectionId)) @PathVariable id: UUID,
@RequestBody body: ArticleMovePayload,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}") @DeleteMapping("/articles/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> { fun delete(
contentService.deleteArticle(id) @PathVariable id: UUID,
): ResponseEntity<Void> {
authoring.deleteArticle(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
} }

View File

@@ -0,0 +1,44 @@
package ru.ysu.lms.academic.controller
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.ContentOrderService
import ru.ysu.lms.academic.content.ReorderArticles
import ru.ysu.lms.academic.content.ReorderSections
import ru.ysu.lms.academic.content.ReorderTree
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class ContentOrderController(
private val order: ContentOrderService,
) {
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
@PostMapping("/reorder")
fun reorder(
@RequestBody body: ReorderTree,
) {
order.reorder(body)
}
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/sections/reorder")
fun reorderSections(
@PathVariable id: UUID,
@RequestBody body: ReorderSections,
) {
order.reorderSections(id, body.sections)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/articles/reorder")
fun reorderArticles(
@PathVariable id: UUID,
@RequestBody body: ReorderArticles,
) {
order.reorderArticles(id, body.articles)
}
}

View File

@@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AssetStorage import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
import java.nio.file.Files
/** Публичное чтение учебных материалов — доступно без аутентификации. */ /** Публичное чтение учебных материалов — доступно без аутентификации. */
@RestController @RestController
@@ -18,7 +17,6 @@ class ContentPublicController(
private val contentService: ContentService, private val contentService: ContentService,
private val assetStorage: AssetStorage, private val assetStorage: AssetStorage,
) { ) {
@GetMapping("/tree") @GetMapping("/tree")
fun tree() = contentService.tree() fun tree() = contentService.tree()
@@ -30,9 +28,15 @@ class ContentPublicController(
): ContentArticleViewDto { ): ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article) val a = contentService.findArticle(chapter, section, article)
return ContentArticleViewDto( return ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle, id = a.id,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle, chapterSlug = a.chapterSlug,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
) )
} }
@@ -49,13 +53,19 @@ class ContentPublicController(
) )
@GetMapping("/assets/{name}") @GetMapping("/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> { fun asset(
val (path, mime) = assetStorage.read(name) @PathVariable name: String,
?: return ResponseEntity.notFound().build() ): ResponseEntity<FileSystemResource> {
val (path, mime) =
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM } val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok() return ResponseEntity
.ok()
.contentType(media) .contentType(media)
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7))) .cacheControl(
.body(FileSystemResource(path)) org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
} }
} }

View File

@@ -1,46 +1,72 @@
package ru.ysu.lms.academic.controller package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import jakarta.validation.Valid import jakarta.validation.Valid
import ru.ysu.lms.academic.lab.* import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult
import ru.ysu.lms.academic.lab.LabService
import ru.ysu.lms.academic.lab.LabSummary
import ru.ysu.lms.academic.lab.LabUpdateRequest
import ru.ysu.lms.academic.lab.LevelRenameRequest
import ru.ysu.lms.academic.lab.LevelSaveRequest
import ru.ysu.lms.academic.lab.VariantSaveRequest
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/labs") @RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) { class LabController(
private val labService: LabService,
) {
@GetMapping @GetMapping
fun list(): List<LabSummary> = labService.list() fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id) fun detail(
@PathVariable id: UUID,
): LabDetail = labService.detail(id)
@PostMapping("/import") @PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult { fun importLab(
@RequestParam("file") file: MultipartFile,
): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes) return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) { fun delete(
@PathVariable id: UUID,
) {
labService.delete(id) labService.delete(id)
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail = fun update(
labService.update(id, body) @PathVariable id: UUID,
@Valid @RequestBody body: LabUpdateRequest,
): LabDetail = labService.update(id, body)
@PostMapping("/{id}/variants") @PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail = fun addVariant(
labService.addVariant(id, body) @PathVariable id: UUID,
@Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.addVariant(id, body)
@PutMapping("/{id}/variants/{variantId}") @PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/variants/{variantId}") @DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail = fun deleteVariant(
labService.deleteVariant(id, variantId) @PathVariable id: UUID,
@PathVariable variantId: UUID,
): LabDetail = labService.deleteVariant(id, variantId)
@PostMapping("/{id}/toggle-status") @PostMapping("/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun toggleStatus(@PathVariable id: UUID) { fun toggleStatus(
@PathVariable id: UUID,
) {
labService.toggleStatus(id) labService.toggleStatus(id)
} }
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body) fun create(
@Valid @RequestBody body: LabCreateRequest,
): LabDetail = labService.create(body)
@PostMapping("/{id}/levels") @PostMapping("/{id}/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addLevel(@PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest): LabDetail { fun addLevel(
@PathVariable id: UUID,
@Valid @RequestBody body: LevelSaveRequest,
): LabDetail {
labService.addLevel(id, body) labService.addLevel(id, body)
return labService.detail(id) return labService.detail(id)
} }
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/levels/{levelId}") @DeleteMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail = fun deleteLevel(
labService.deleteLevel(id, levelId) @PathVariable id: UUID,
} @PathVariable levelId: UUID,
): LabDetail = labService.deleteLevel(id, levelId)
}

View File

@@ -38,4 +38,4 @@ class ContentArticle(
@Column(nullable = false) var bodyMd: String = "", @Column(nullable = false) var bodyMd: String = "",
@Column(nullable = false) var position: Int = 0, @Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true, @Column(nullable = false) var published: Boolean = true,
) )

View File

@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.JoinColumn import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table import jakarta.persistence.Table
import ru.ysu.lms.common.Ids import ru.ysu.lms.common.Ids
import java.time.Instant import java.time.Instant
@@ -37,4 +35,4 @@ class Lab(
var status: String = "draft", var status: String = "draft",
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(), var createdAt: Instant = Instant.now(),
) )

View File

@@ -28,4 +28,4 @@ class LabLevel(
var position: Int = 0, var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)") @Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null, var pointsMax: java.math.BigDecimal? = null,
) )

View File

@@ -18,4 +18,4 @@ class Semester(
var year: Int = 0, var year: Int = 0,
@Column(nullable = false) @Column(nullable = false)
var active: Boolean = false, var active: Boolean = false,
) )

View File

@@ -29,4 +29,4 @@ class Variant(
var statementMd: String = "", var statementMd: String = "",
@Column(nullable = false) @Column(nullable = false)
var position: Int = 0, var position: Int = 0,
) )

View File

@@ -119,4 +119,4 @@ data class LabCreateRequest(
data class LevelRenameRequest( data class LevelRenameRequest(
val title: String, val title: String,
val description: String? = null, val description: String? = null,
) )

View File

@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
*/ */
@Component @Component
class LabImportParser { class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory()) private val yaml =
.registerModule(KotlinModule.Builder().build()) ObjectMapper(YAMLFactory())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) .registerModule(KotlinModule.Builder().build())
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(filename: String, bytes: ByteArray): LabImportBundle { fun parse(
filename: String,
bytes: ByteArray,
): LabImportBundle {
if (!filename.lowercase().endsWith(".zip")) { if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)") throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
} }
@@ -29,91 +33,117 @@ class LabImportParser {
} }
private fun parseZip(bytes: ByteArray): LabImportBundle { private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = readEntries(bytes)
val meta = readMeta(entries)
val readmeMd = readReadme(entries)
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val normalized = path.replace('\\', '/')
when {
isInDir(normalized, "variants") ->
parseVariantFile(normalized, String(data, Charsets.UTF_8), meta.levels)?.let(variants::add)
isInDir(normalized, "assets") -> assets[normalized] = data
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>() val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry var entry = zis.nextEntry
while (entry != null) { while (entry != null) {
if (!entry.isDirectory) { if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
entries[entry.name] = zis.readAllBytes()
}
entry = zis.nextEntry entry = zis.nextEntry
} }
} }
return entries
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
} }
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? { private fun readMeta(entries: Map<String, ByteArray>): LabMeta {
val levelMap = levels.associateBy { it.code } val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
return yaml.readValue(metaEntry.value, LabMeta::class.java)
}
private fun readReadme(entries: Map<String, ByteArray>): String =
entries.entries
.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
?.let { String(it.value, Charsets.UTF_8) }
?: ""
private fun isInDir(
path: String,
dir: String,
): Boolean = path.contains("/$dir/") || path.startsWith("$dir/")
private fun parseVariantFile(
path: String,
content: String,
levels: List<LabLevelMeta>,
): VariantMeta? {
val lines = content.lines() val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" } val frontMatter =
if (firstSep != 0) { readFrontMatter(lines)
return VariantMeta( ?: return VariantMeta(
level = "basic", level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"), number = path.substringAfterLast("/").removeSuffix(".md"),
title = "", title = "",
statement = content.trim(), statement = content.trim(),
) )
} val levelCode = normalizeLevel(frontMatter.values["level"] ?: "basic")
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1 val number = frontMatter.values["number"]?.takeIf { it.isNotBlank() }
if (secondSep < 0) return null if (levels.none { it.code == levelCode } || number == null) return null
val fmLines = lines.subList(1, secondSep) val body = lines.drop(frontMatter.bodyStart).joinToString("\n").trim()
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = unquote(parts[1].trim())
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta( return VariantMeta(
level = levelCode, level = levelCode,
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null, number = number,
title = fm["title"] ?: "", title = frontMatter.values["title"] ?: "",
statement = body.ifBlank { "—" }, statement = body.ifBlank { "—" },
) )
} }
private fun unquote(value: String): String = private data class FrontMatter(
if (value.length >= 2 && val values: Map<String, String>,
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\'')) val bodyStart: Int,
) { )
value.substring(1, value.length - 1)
} else {
value
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) { /** Читает YAML-front-matter (между двумя `---`), иначе возвращает null. */
"main", "basic", "обычная", "основная" -> "basic" private fun readFrontMatter(lines: List<String>): FrontMatter? {
"advanced", "повышенная", "повышенной", "hard" -> "advanced" if (lines.firstOrNull()?.trim() != "---") return null
else -> s.lowercase().trim() val end =
lines
.withIndex()
.drop(1)
.firstOrNull { it.value.trim() == "---" }
?.index ?: return null
val values = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) values[parts[0].trim()] = unquote(parts[1].trim())
}
return FrontMatter(values, end + 1)
} }
}
private fun unquote(value: String): String = if (isQuoted(value)) value.substring(1, value.length - 1) else value
private fun isQuoted(value: String): Boolean {
if (value.length < 2) return false
val first = value.first()
val last = value.last()
return (first == '"' && last == '"') || (first == '\'' && last == '\'')
}
private fun normalizeLevel(s: String): String =
when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -28,12 +28,15 @@ class LabService(
private val semesters: SemesterRepository, private val semesters: SemesterRepository,
private val parser: LabImportParser, private val parser: LabImportParser,
) { ) {
@Transactional @Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult { fun importFromFile(
filename: String,
bytes: ByteArray,
): LabImportResult {
val bundle = parser.parse(filename, bytes) val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue() val semester =
?: throw BadRequestException("Нет активного семестра. Создайте семестр.") semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) { if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
throw ConflictException( throw ConflictException(
@@ -41,47 +44,44 @@ class LabService(
) )
} }
val lab = labs.save(Lab( val lab =
number = bundle.meta.number, labs.save(
title = bundle.meta.title, Lab(
semester = semester, number = bundle.meta.number,
descriptionMd = bundle.descriptionMd, title = bundle.meta.title,
pointsMax = bundle.meta.pointsMax, semester = semester,
status = bundle.meta.status, descriptionMd = bundle.descriptionMd,
deadlineAt = parseInstant(bundle.meta.deadlineAt), pointsMax = bundle.meta.pointsMax,
defenseAt = parseInstant(bundle.meta.defenseAt), status = bundle.meta.status,
)) deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
val levelMetas = bundle.meta.levels.ifEmpty { ),
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
) )
}
val levelEntities = levelMetas.map { meta -> val levelMetas =
labLevels.save(LabLevel( bundle.meta.levels.ifEmpty {
lab = lab, listOf(
code = meta.code, LabLevelMeta("basic", "Основная группа", position = 1),
title = meta.title, LabLevelMeta("advanced", "Повышенная сложность", position = 2),
description = meta.description.orEmpty(), )
position = meta.position ?: 0, }
pointsMax = meta.pointsMax, val levelEntities =
)) levelMetas.map { meta ->
} labLevels.save(
LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
),
)
}
val levelByCode = levelEntities.associateBy { it.code } val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta -> saveVariants(lab, levelByCode, bundle.variants)
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,
))
}
return LabImportResult( return LabImportResult(
labId = lab.id.toString(), labId = lab.id.toString(),
@@ -122,25 +122,29 @@ class LabService(
deadlineAt = lab.deadlineAt?.toString(), deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(), defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax, pointsMax = lab.pointsMax,
levels = levels.map { level -> levels =
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой). levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id) // Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
.filter { it.labLevel?.id == level.id } val vs =
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number })) variantsRepo
LabLevelDetail( .findByLabIdOrderByNumber(lab.id)
id = level.id.toString(), .filter { it.labLevel?.id == level.id }
code = level.code, .sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
title = level.title, LabLevelDetail(
variants = vs.map { v -> id = level.id.toString(),
VariantDetail( code = level.code,
id = v.id.toString(), title = level.title,
number = v.number, variants =
title = v.title, vs.map { v ->
statementMd = v.statementMd, VariantDetail(
) id = v.id.toString(),
}, number = v.number,
) title = v.title,
}, statementMd = v.statementMd,
)
},
)
},
) )
} }
@@ -153,7 +157,10 @@ class LabService(
} }
@Transactional @Transactional
fun update(labId: UUID, request: LabUpdateRequest): LabDetail { fun update(
labId: UUID,
request: LabUpdateRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым") if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
lab.title = request.title.trim() lab.title = request.title.trim()
@@ -167,70 +174,77 @@ class LabService(
} }
@Transactional @Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail { fun updateVariant(
val variant = variantsRepo.findById(variantId) labId: UUID,
.orElseThrow { NotFoundException("Вариант не найден") } variantId: UUID,
request: VariantSaveRequest,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым") if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim() variant.number = request.number.trim()
variant.title = request.title.orEmpty().trim() variant.title = request.title.orEmpty().trim()
variant.statementMd = request.statementMd.orEmpty() variant.statementMd = request.statementMd.orEmpty()
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId -> request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId)) variant.labLevel =
.orElseThrow { BadRequestException("Уровень не найден") } labLevels
.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
} }
variantsRepo.save(variant) variantsRepo.save(variant)
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail { fun addVariant(
labId: UUID,
request: VariantSaveRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым") if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
val level = labLevels.findById(UUID.fromString(request.labLevelId)) val level =
.orElseThrow { BadRequestException("Уровень не найден") } labLevels
variantsRepo.save(Variant( .findById(UUID.fromString(request.labLevelId))
lab = lab, .orElseThrow { BadRequestException("Уровень не найден") }
labLevel = level, variantsRepo.save(
number = request.number.trim(), Variant(
title = request.title.orEmpty().trim(), lab = lab,
statementMd = request.statementMd.orEmpty(), labLevel = level,
)) number = request.number.trim(),
title = request.title.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
),
)
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail { fun deleteVariant(
val variant = variantsRepo.findById(variantId) labId: UUID,
.orElseThrow { NotFoundException("Вариант не найден") } variantId: UUID,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant) variantsRepo.delete(variant)
return detail(labId) return detail(labId)
} }
@Transactional private fun validateCreate(request: LabCreateRequest) {
fun create(request: LabCreateRequest): LabDetail {
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым") if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным") if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0) }
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") private fun saveLevels(
} lab: Lab,
val lab = labs.save( levels: List<LevelSaveRequest>,
Lab( ) {
number = request.number ?: 0, val levelMetas = levels.ifEmpty { listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) }
title = request.title.orEmpty().trim(),
semester = semester,
descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt),
pointsMax = request.pointsMax ?: BigDecimal(5),
status = request.status ?: "draft",
),
)
val levelMetas = request.levels.ifEmpty {
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
}
levelMetas.forEachIndexed { index, meta -> levelMetas.forEachIndexed { index, meta ->
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым") if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
labLevels.save( labLevels.save(
@@ -243,11 +257,59 @@ class LabService(
), ),
) )
} }
}
private fun saveVariants(
lab: Lab,
levelByCode: Map<String, LabLevel>,
variantMetas: List<VariantMeta>,
) {
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} уже существует в этом семестре")
}
val lab =
labs.save(
Lab(
number = request.number ?: 0,
title = request.title.orEmpty().trim(),
semester = semester,
descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt),
pointsMax = request.pointsMax ?: BigDecimal(5),
status = request.status ?: "draft",
),
)
saveLevels(lab, request.levels)
return detail(lab.id) return detail(lab.id)
} }
@Transactional @Transactional
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail { fun renameLevel(
labId: UUID,
levelId: UUID,
request: LevelRenameRequest,
): LabDetail {
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") } val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден") if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
level.title = request.title.ifBlank { level.title } level.title = request.title.ifBlank { level.title }
@@ -257,7 +319,10 @@ class LabService(
} }
@Transactional @Transactional
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail { fun deleteLevel(
labId: UUID,
levelId: UUID,
): LabDetail {
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") } val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден") if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) { if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
@@ -267,8 +332,11 @@ class LabService(
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun addLevel(labId: UUID, request: LevelSaveRequest) { fun addLevel(
labId: UUID,
request: LevelSaveRequest,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым") if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
val existing = labLevels.findByLabIdOrderByPosition(labId) val existing = labLevels.findByLabIdOrderByPosition(labId)
@@ -290,15 +358,24 @@ class LabService(
labs.save(lab) labs.save(lab)
} }
private fun findOrCreateSemester(title: String, year: Int): Semester { private fun findOrCreateSemester(
title: String,
year: Int,
): Semester {
semesters.findFirstByActiveTrue()?.let { return it } semesters.findFirstByActiveTrue()?.let { return it }
val t = title.ifBlank { "осень" } val t = title.ifBlank { "осень" }
val y = if (year > 0) year else java.time.LocalDate.now().year val y =
if (year > 0) {
year
} else {
java.time.LocalDate
.now()
.year
}
return semesters.save(Semester(title = t, year = y, active = true)) return semesters.save(Semester(title = t, year = y, active = true))
} }
private fun naturalNumber(value: String): Int = private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
private fun parseInstant(value: String?): Instant? { private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null val v = value?.takeIf { it.isNotBlank() } ?: return null
@@ -307,4 +384,4 @@ class LabService(
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() } .recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
.getOrNull() .getOrNull()
} }
} }

View File

@@ -8,20 +8,40 @@ import java.util.UUID
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> { interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter> fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
fun findBySlug(slug: String): ContentChapter? fun findBySlug(slug: String): ContentChapter?
fun findByTitleIgnoreCase(title: String): ContentChapter? fun findByTitleIgnoreCase(title: String): ContentChapter?
} }
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> { interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection> fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection> fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection? fun findByChapterIdAndSlug(
chapterId: UUID,
slug: String,
): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(
chapterId: UUID,
title: String,
): ContentSection?
} }
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> { interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle> fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle? fun findBySectionIdAndSlug(
sectionId: UUID,
slug: String,
): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(
sectionId: UUID,
title: String,
): ContentArticle?
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle> fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
} }

View File

@@ -6,5 +6,9 @@ import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> { interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab> fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
} fun existsByNumberAndSemesterId(
number: Int,
semesterId: UUID,
): Boolean
}

View File

@@ -6,4 +6,4 @@ import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> { interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester? fun findFirstByActiveTrue(): Semester?
} }

View File

@@ -11,4 +11,4 @@ interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
interface VariantRepository : JpaRepository<Variant, UUID> { interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant> fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
} }

View File

@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtDecoder
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
class SecurityConfig { class SecurityConfig {
@Bean @Bean
fun jwtDecoder(): JwtDecoder = fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean @Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> { fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter() val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt -> converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role") val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList() if (role.isNullOrBlank()) {
else listOf(SimpleGrantedAuthority("ROLE_$role")) emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
} }
return converter return converter
} }
@@ -43,17 +43,17 @@ class SecurityConfig {
.cors { it.disable() } .cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
auth.requestMatchers( auth
"/actuator/**", .requestMatchers(
"/v3/api-docs/**", "/actuator/**",
"/swagger-ui/**", "/v3/api-docs/**",
"/api/v1/content/tree", "/swagger-ui/**",
"/api/v1/content/assets/**", "/api/v1/content/tree",
"/api/v1/content/{chapter}/{section}/{article}", "/api/v1/content/assets/**",
).permitAll() "/api/v1/content/{chapter}/{section}/{article}",
).permitAll()
auth.anyRequest().authenticated() auth.anyRequest().authenticated()
} }.oauth2ResourceServer { oauth ->
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt -> oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
} }
@@ -64,4 +64,4 @@ class SecurityConfig {
companion object { companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json" private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
} }
} }

View File

@@ -0,0 +1,87 @@
package ru.ysu.lms.academic.content
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Интеграционный тест импорта материалов: реальная PostgreSQL (Testcontainers) + Flyway.
* Проверяет, что повторный импорт не плодит дубли (сопоставление по названию).
*/
@Testcontainers
@SpringBootTest
class ContentImportIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
@JvmStatic
@DynamicPropertySource
fun assetsDir(registry: DynamicPropertyRegistry) {
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
}
private fun articleZip(): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md"))
zos.write(
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\nТело статьи".toByteArray(),
)
zos.closeEntry()
}
return out.toByteArray()
}
}
@Autowired
private lateinit var importer: ContentImporter
@Autowired
private lateinit var chapters: ContentChapterRepository
@Autowired
private lateinit var sections: ContentSectionRepository
@Autowired
private lateinit var articles: ContentArticleRepository
@Test
fun `re-import does not duplicate chapter, section or article`() {
val zip = articleZip()
val first = importer.importZip("content.zip", zip, sync = false)
assertThat(first.chapters).isEqualTo(1)
val chapter = chapters.findByTitleIgnoreCase("Тестовая")
assertThat(chapter).isNotNull
val section = sections.findByChapterIdAndTitleIgnoreCase(chapter!!.id, "Раздел")
assertThat(section).isNotNull
val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья")
assertThat(article).isNotNull
assertThat(article!!.slug).isEqualTo("test-article")
// повторный импорт того же архива обновляет существующие записи, а не создаёт новые
importer.importZip("content.zip", zip, sync = false)
assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id)
assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id)).hasSize(1)
assertThat(articles.findBySectionIdOrderByPositionAscIdAsc(section.id)).hasSize(1)
}
}

View File

@@ -0,0 +1,96 @@
package ru.ysu.lms.academic.content
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.io.ByteArrayOutputStream
import java.nio.file.Path
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class ContentImporterTest {
@TempDir
lateinit var tempDir: Path
private val chapters = mockk<ContentChapterRepository>()
private val sections = mockk<ContentSectionRepository>()
private val articles = mockk<ContentArticleRepository>()
private fun importer(): ContentImporter {
every { chapters.findByTitleIgnoreCase(any()) } returns null
every { chapters.findBySlug(any()) } returns null
every { chapters.findAllByOrderByPositionAscIdAsc() } returns emptyList()
every { chapters.save(any()) } answers { firstArg() }
every { sections.findByChapterIdAndTitleIgnoreCase(any(), any()) } returns null
every { sections.findByChapterIdAndSlug(any(), any()) } returns null
every { sections.findByChapterIdOrderByPositionAscIdAsc(any()) } returns emptyList()
every { sections.save(any()) } answers { firstArg() }
every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null
every { articles.findBySectionIdAndSlug(any(), any()) } returns null
every { articles.save(any()) } answers { firstArg() }
return ContentImporter(chapters, sections, articles, AssetStorage(tempDir.toString()))
}
private fun articleZip(
title: String,
slug: String,
): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("chapters/01-Глава/01-Раздел/01-Статья.md"))
zos.write("---\ntitle: \"$title\"\nslug: \"$slug\"\n---\nТекст статьи".toByteArray())
zos.closeEntry()
}
return out.toByteArray()
}
@Test
fun `creates chapter, section and article with title-based slug`() {
val importer = importer()
val savedChapter = slot<ContentChapter>()
val savedArticle = slot<ContentArticle>()
every { chapters.save(capture(savedChapter)) } answers { firstArg() }
every { articles.save(capture(savedArticle)) } answers { firstArg() }
val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false)
assertThat(result.chapters).isEqualTo(1)
assertThat(result.sections).isEqualTo(1)
assertThat(result.articles).isEqualTo(1)
assertThat(savedChapter.captured.title).isEqualTo("Глава")
assertThat(savedChapter.captured.slug).isEqualTo("glava")
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
assertThat(savedArticle.captured.slug).isEqualTo("c-input")
}
@Test
fun `reuses existing chapter and section by title instead of duplicating`() {
val importer = importer()
val chapter = ContentChapter(title = "Глава", slug = "glava", position = 1)
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
val chapterSaves = mutableListOf<UUID>()
every { chapters.save(any()) } answers {
firstArg<ContentChapter>().also { chapterSaves.add(it.id) }
}
val result = importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
assertThat(result.chapters).isEqualTo(1)
assertThat(chapterSaves).containsExactly(chapter.id)
}
}

View File

@@ -0,0 +1,95 @@
package ru.ysu.lms.academic.lab
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class LabImportParserTest {
private val parser = LabImportParser()
private fun zip(vararg files: Pair<String, String>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
for ((name, content) in files) {
zos.putNextEntry(ZipEntry(name))
zos.write(content.toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
return out.toByteArray()
}
private val labYaml =
"""
number: 2
title: "Многомерные массивы и функции"
levels:
- code: basic
title: "Основная"
- code: advanced
title: "Повышенная"
""".trimIndent()
@Test
fun `parses lab metadata, readme and variants`() {
val bundle =
parser.parse(
"lab.zip",
zip(
"lab.yaml" to labYaml,
"README.md" to "# Условие лабораторной",
"variants/1.md" to "---\nlevel: basic\nnumber: \"1\"\ntitle: \"Сумма\"\n---\nНайдите сумму",
"variants/p1.md" to "---\nlevel: advanced\nnumber: \"П1\"\ntitle: \"Сложная\"\n---\nЗадание",
),
)
assertThat(bundle.meta.number).isEqualTo(2)
assertThat(bundle.meta.title).isEqualTo("Многомерные массивы и функции")
assertThat(bundle.meta.levels).hasSize(2)
assertThat(bundle.descriptionMd).contains("Условие лабораторной")
assertThat(bundle.variants).hasSize(2)
assertThat(bundle.variants.map { it.number }).containsExactlyInAnyOrder("1", "П1")
assertThat(bundle.variants.first { it.number == "1" }.level).isEqualTo("basic")
assertThat(bundle.variants.first { it.number == "1" }.statement).isEqualTo("Найдите сумму")
}
@Test
fun `treats file without front matter as basic variant`() {
val bundle =
parser.parse(
"lab.zip",
zip(
"lab.yaml" to labYaml,
"variants/7.md" to "Просто текст задания",
),
)
assertThat(bundle.variants).hasSize(1)
assertThat(bundle.variants.single().level).isEqualTo("basic")
assertThat(bundle.variants.single().statement).contains("Просто текст задания")
}
@Test
fun `rejects non zip file`() {
assertThatThrownBy { parser.parse("lab.txt", ByteArray(0)) }
.isInstanceOf(BadRequestException::class.java)
}
@Test
fun `rejects zip without lab yaml`() {
assertThatThrownBy { parser.parse("lab.zip", zip("README.md" to "# Условие")) }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("lab.yaml")
}
@Test
fun `rejects zip without variants`() {
assertThatThrownBy { parser.parse("lab.zip", zip("lab.yaml" to labYaml, "README.md" to "# Условие")) }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("варианты")
}
}

View File

@@ -16,22 +16,30 @@ import reactor.core.publisher.Mono
* identity to the downstream services as `X-Account-*` headers. * identity to the downstream services as `X-Account-*` headers.
*/ */
@Component @Component
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered { class JwtAuthenticationFilter(
private val jwtDecoder: JwtDecoder,
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> { ) : GlobalFilter,
Ordered {
override fun filter(
exchange: ServerWebExchange,
chain: GatewayFilterChain,
): Mono<Void> {
val path = exchange.request.path.value() val path = exchange.request.path.value()
if (isPublic(path)) { if (isPublic(path)) {
return chain.filter(exchange) return chain.filter(exchange)
} }
val token = bearerToken(exchange) val token =
?: return unauthorized(exchange, "Требуется вход") bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
return try { return try {
val jwt = jwtDecoder.decode(token) val jwt = jwtDecoder.decode(token)
val request = exchange.request.mutate() val request =
.header("X-Account-Id", jwt.subject.orEmpty()) exchange.request
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty()) .mutate()
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty()) .header("X-Account-Id", jwt.subject.orEmpty())
.build() .header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
.build()
chain.filter(exchange.mutate().request(request).build()) chain.filter(exchange.mutate().request(request).build())
} catch (_: JwtException) { } catch (_: JwtException) {
unauthorized(exchange, "Недействительный или истёкший токен") unauthorized(exchange, "Недействительный или истёкший токен")
@@ -56,7 +64,10 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
return header.removePrefix("Bearer ").trim().ifBlank { null } return header.removePrefix("Bearer ").trim().ifBlank { null }
} }
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> { private fun unauthorized(
exchange: ServerWebExchange,
message: String,
): Mono<Void> {
val response = exchange.response val response = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.contentType = MediaType.APPLICATION_JSON response.headers.contentType = MediaType.APPLICATION_JSON

View File

@@ -18,23 +18,28 @@ class AuthController(
private val authService: AuthService, private val authService: AuthService,
private val rsaKey: RSAKey, private val rsaKey: RSAKey,
) { ) {
@GetMapping("/health") @GetMapping("/health")
fun health(): Map<String, Any> = mapOf( fun health(): Map<String, Any> =
"ok" to true, mapOf(
"service" to "identity-service", "ok" to true,
"adminExists" to authService.adminExists(), "service" to "identity-service",
) "adminExists" to authService.adminExists(),
)
@PostMapping("/setup") @PostMapping("/setup")
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body) fun setup(
@Valid @RequestBody body: SetupRequest,
): AuthResponse = authService.setup(body)
@PostMapping("/login") @PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body) fun login(
@Valid @RequestBody body: LoginRequest,
): AuthResponse = authService.login(body)
@GetMapping("/me") @GetMapping("/me")
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse = fun me(
authService.me(UUID.fromString(jwt.subject)) @AuthenticationPrincipal jwt: Jwt,
): MeResponse = authService.me(UUID.fromString(jwt.subject))
@PostMapping("/change-password") @PostMapping("/change-password")
fun changePassword( fun changePassword(

View File

@@ -28,7 +28,6 @@ class AuthService(
private val jwtEncoder: JwtEncoder, private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
) { ) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN) fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@Transactional @Transactional
@@ -41,20 +40,22 @@ class AuthService(
throw BadRequestException("Логин: минимум 3 символа") throw BadRequestException("Логин: минимум 3 символа")
} }
validatePassword(request.password, request.password2) validatePassword(request.password, request.password2)
val account = Account( val account =
login = login, Account(
passwordHash = passwordEncoder.encode(request.password) ?: "", login = login,
role = LmsRole.ADMIN, passwordHash = passwordEncoder.encode(request.password) ?: "",
mustChangePassword = false, role = LmsRole.ADMIN,
) mustChangePassword = false,
)
accounts.save(account) accounts.save(account)
return toResponse(account, account.login) return toResponse(account, account.login)
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun login(request: LoginRequest): AuthResponse { fun login(request: LoginRequest): AuthResponse {
val account = accounts.findByLogin(request.login.trim()) val account =
?: throw UnauthorizedException("Неверный логин или пароль") accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) { if (!passwordEncoder.matches(request.password, account.passwordHash)) {
throw UnauthorizedException("Неверный логин или пароль") throw UnauthorizedException("Неверный логин или пароль")
} }
@@ -63,8 +64,10 @@ class AuthService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse { fun me(accountId: UUID): MeResponse {
val account = accounts.findById(accountId) val account =
.orElseThrow { NotFoundException("Пользователь не найден") } accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
return MeResponse( return MeResponse(
id = account.id.toString(), id = account.id.toString(),
login = account.login, login = account.login,
@@ -75,9 +78,14 @@ class AuthService(
} }
@Transactional @Transactional
fun changePassword(accountId: UUID, request: ChangePasswordRequest) { fun changePassword(
val account = accounts.findById(accountId) accountId: UUID,
.orElseThrow { NotFoundException("Пользователь не найден") } request: ChangePasswordRequest,
) {
val account =
accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
if (!passwordEncoder.matches(request.old, account.passwordHash)) { if (!passwordEncoder.matches(request.old, account.passwordHash)) {
throw UnauthorizedException("Текущий пароль неверный") throw UnauthorizedException("Текущий пароль неверный")
} }
@@ -89,7 +97,10 @@ class AuthService(
accounts.save(account) accounts.save(account)
} }
private fun validatePassword(password: String, confirmation: String) { private fun validatePassword(
password: String,
confirmation: String,
) {
if (password.length < 8) { if (password.length < 8) {
throw BadRequestException("Пароль: минимум 8 символов") throw BadRequestException("Пароль: минимум 8 символов")
} }
@@ -100,33 +111,43 @@ class AuthService(
private fun displayName(account: Account): String = private fun displayName(account: Account): String =
if (account.role == LmsRole.STUDENT) { if (account.role == LmsRole.STUDENT) {
students.findById(account.id) students
.findById(account.id)
.map { it.fullName.ifBlank { account.login } } .map { it.fullName.ifBlank { account.login } }
.orElse(account.login) .orElse(account.login)
} else { } else {
account.login account.login
} }
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse( private fun toResponse(
token = issueToken(account, name), account: Account,
id = account.id.toString(), name: String,
login = account.login, ): AuthResponse =
role = account.role.name, AuthResponse(
name = name, token = issueToken(account, name),
mustChangePassword = account.mustChangePassword, id = account.id.toString(),
) login = account.login,
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
)
private fun issueToken(account: Account, name: String): String { private fun issueToken(
account: Account,
name: String,
): String {
val now = Instant.now() val now = Instant.now()
val claims = JwtClaimsSet.builder() val claims =
.issuer(jwtProperties.issuer) JwtClaimsSet
.issuedAt(now) .builder()
.expiresAt(now.plus(jwtProperties.accessTokenTtl)) .issuer(jwtProperties.issuer)
.subject(account.id.toString()) .issuedAt(now)
.claim("role", account.role.name) .expiresAt(now.plus(jwtProperties.accessTokenTtl))
.claim("login", account.login) .subject(account.id.toString())
.claim("name", name) .claim("role", account.role.name)
.build() .claim("login", account.login)
.claim("name", name)
.build()
val header = JwsHeader.with(SignatureAlgorithm.RS256).build() val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
} }

View File

@@ -16,26 +16,19 @@ import java.util.UUID
class Account( class Account(
@Id @Id
var id: UUID = Ids.v7(), var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 64) @Column(nullable = false, unique = true, length = 64)
var login: String = "", var login: String = "",
@Column(name = "password_hash", nullable = false) @Column(name = "password_hash", nullable = false)
var passwordHash: String = "", var passwordHash: String = "",
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16) @Column(nullable = false, length = 16)
var role: LmsRole = LmsRole.STUDENT, var role: LmsRole = LmsRole.STUDENT,
@Column(name = "must_change_password", nullable = false) @Column(name = "must_change_password", nullable = false)
var mustChangePassword: Boolean = true, var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64) @Column(name = "temp_password", length = 64)
var tempPassword: String? = null, var tempPassword: String? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(), var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(), var updatedAt: Instant = Instant.now(),
) )

View File

@@ -16,15 +16,12 @@ import java.util.UUID
class Enrollment( class Enrollment(
@Id @Id
var id: UUID = Ids.v7(), var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "group_id", nullable = false) @JoinColumn(name = "group_id", nullable = false)
var group: GroupEntity? = null, var group: GroupEntity? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id", nullable = false) @JoinColumn(name = "student_id", nullable = false)
var student: Student? = null, var student: Student? = null,
@Column(name = "enrolled_at", nullable = false) @Column(name = "enrolled_at", nullable = false)
var enrolledAt: Instant = Instant.now(), var enrolledAt: Instant = Instant.now(),
) )

View File

@@ -13,13 +13,10 @@ import java.util.UUID
class GroupEntity( class GroupEntity(
@Id @Id
var id: UUID = Ids.v7(), var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100) @Column(nullable = false, unique = true, length = 100)
var name: String = "", var name: String = "",
@Column(name = "task_duration_min", nullable = false) @Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30, var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(), var createdAt: Instant = Instant.now(),
) )

View File

@@ -11,7 +11,6 @@ class Setting(
@Id @Id
@Column(name = "key", length = 100) @Column(name = "key", length = 100)
var key: String = "", var key: String = "",
@Column(name = "value", nullable = false) @Column(name = "value", nullable = false)
var value: String = "", var value: String = "",
) )

View File

@@ -16,32 +16,26 @@ class Student(
@Id @Id
@Column(name = "account_id") @Column(name = "account_id")
var accountId: UUID? = null, var accountId: UUID? = null,
@Column(name = "student_no", nullable = false, unique = true, length = 32) @Column(name = "student_no", nullable = false, unique = true, length = 32)
var studentNo: String = "", var studentNo: String = "",
@Column(name = "last_name", nullable = false, length = 100) @Column(name = "last_name", nullable = false, length = 100)
var lastName: String = "", var lastName: String = "",
@Column(name = "first_name", nullable = false, length = 100) @Column(name = "first_name", nullable = false, length = 100)
var firstName: String = "", var firstName: String = "",
@Column(name = "middle_name", nullable = false, length = 100) @Column(name = "middle_name", nullable = false, length = 100)
var middleName: String = "", var middleName: String = "",
@Column(name = "basis", nullable = false, length = 100) @Column(name = "basis", nullable = false, length = 100)
var basis: String = "", var basis: String = "",
@Column(name = "note", nullable = false) @Column(name = "note", nullable = false)
var note: String = "", var note: String = "",
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@MapsId @MapsId
@JoinColumn(name = "account_id") @JoinColumn(name = "account_id")
var account: Account? = null, var account: Account? = null,
) { ) {
val fullName: String val fullName: String
get() = listOf(lastName, firstName, middleName) get() =
.filter { it.isNotBlank() } listOf(lastName, firstName, middleName)
.joinToString(" ") .filter { it.isNotBlank() }
.joinToString(" ")
} }

View File

@@ -7,7 +7,10 @@ import java.util.UUID
interface AccountRepository : JpaRepository<Account, UUID> { interface AccountRepository : JpaRepository<Account, UUID> {
fun findByLogin(login: String): Account? fun findByLogin(login: String): Account?
fun existsByLogin(login: String): Boolean fun existsByLogin(login: String): Boolean
fun existsByRole(role: LmsRole): Boolean fun existsByRole(role: LmsRole): Boolean
fun countByRole(role: LmsRole): Long fun countByRole(role: LmsRole): Long
} }

View File

@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
import java.util.UUID import java.util.UUID
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> { interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
@Query( @Query(
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId", "select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
) )
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment> fun findAllByGroupId(
@Param("groupId") groupId: UUID,
): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment? fun findByStudentAccountId(accountId: UUID): Enrollment?
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID) fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
fun countByGroup_Id(groupId: UUID): Long fun deleteByStudentAccountId(accountId: UUID)
fun countByGroupId(groupId: UUID): Long
} }

View File

@@ -6,6 +6,8 @@ import java.util.UUID
interface GroupRepository : JpaRepository<GroupEntity, UUID> { interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findByName(name: String): GroupEntity? fun findByName(name: String): GroupEntity?
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
fun findAllByOrderByNameAsc(): List<GroupEntity> fun findAllByOrderByNameAsc(): List<GroupEntity>
} }

View File

@@ -6,5 +6,6 @@ import java.util.UUID
interface StudentRepository : JpaRepository<Student, UUID> { interface StudentRepository : JpaRepository<Student, UUID> {
fun findByStudentNo(studentNo: String): Student? fun findByStudentNo(studentNo: String): Student?
fun existsByStudentNo(studentNo: String): Boolean fun existsByStudentNo(studentNo: String): Boolean
} }

View File

@@ -4,21 +4,21 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
object CredentialsExporter { object CredentialsExporter {
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус") private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
fun csv(rows: List<StudentRow>): ByteArray { fun csv(rows: List<StudentRow>): ByteArray {
val builder = StringBuilder() val builder = StringBuilder()
builder.append(HEADERS.joinToString(";")).append("\r\n") builder.append(HEADERS.joinToString(";")).append("\r\n")
rows.forEach { row -> rows.forEach { row ->
builder.append( builder
listOf( .append(
row.fullName, listOf(
row.studentNo, row.fullName,
row.tempPassword ?: "—", row.studentNo,
status(row), row.tempPassword ?: "—",
).joinToString(";") { escapeCsv(it) }, status(row),
).append("\r\n") ).joinToString(";") { escapeCsv(it) },
).append("\r\n")
} }
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8) return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
} }
@@ -41,8 +41,7 @@ object CredentialsExporter {
} }
} }
private fun status(row: StudentRow): String = private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun escapeCsv(value: String): String = private fun escapeCsv(value: String): String =
if (value.contains(';') || value.contains('"') || value.contains('\n')) { if (value.contains(';') || value.contains('"') || value.contains('\n')) {

View File

@@ -5,6 +5,8 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/groups") @RequestMapping("/api/v1/groups")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class GroupController(private val groupService: GroupService) { class GroupController(
private val groupService: GroupService,
) {
@GetMapping @GetMapping
fun list(): List<GroupSummary> = groupService.list() fun list(): List<GroupSummary> = groupService.list()
@PostMapping @PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body) fun create(
@Valid @RequestBody body: CreateGroupRequest,
): GroupSummary = groupService.create(body)
@PostMapping("/{id}/students") @PostMapping("/{id}/students")
fun createStudent( fun createStudent(
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */ /** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@GetMapping("/{id}/candidates") @GetMapping("/{id}/candidates")
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id) fun candidates(
@PathVariable id: UUID,
): List<StudentCandidate> = groupService.candidates(id)
/** Зачислить существующего студента. */ /** Зачислить существующего студента. */
@PostMapping("/{id}/students/{studentId}/enroll") @PostMapping("/{id}/students/{studentId}/enroll")
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail = fun enroll(
groupService.enroll(id, studentId) @PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.enroll(id, studentId)
/** Отчислить студента из группы. */ /** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}") @DeleteMapping("/{id}/students/{studentId}")
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail = fun unenroll(
groupService.unenroll(id, studentId) @PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.unenroll(id, studentId)
@GetMapping("/me") @GetMapping("/me")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> = fun myGroup(
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList() @AuthenticationPrincipal jwt: Jwt,
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id) fun detail(
@PathVariable id: UUID,
): GroupDetail = groupService.detail(id)
@GetMapping("/{id}/credentials") @GetMapping("/{id}/credentials")
fun credentials( fun credentials(
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val export = groupService.credentials(id, format) val export = groupService.credentials(id, format)
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20") val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok() return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
.contentType(MediaType.parseMediaType(export.contentType)) .contentType(MediaType.parseMediaType(export.contentType))
.body(export.bytes) .body(export.bytes)

View File

@@ -27,12 +27,14 @@ class GroupService(
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser, private val parser: StudentImportParser,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) } fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional @Transactional
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential { fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val studentNo = request.studentNo.trim() val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым") if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
@@ -40,27 +42,29 @@ class GroupService(
throw ConflictException("Студент с номером $studentNo уже существует") throw ConflictException("Студент с номером $studentNo уже существует")
} }
val password = Passwords.generate() val password = Passwords.generate()
val account = accounts.save( val account =
Account( accounts.save(
login = studentNo, Account(
passwordHash = passwordEncoder.encode(password) ?: "", login = studentNo,
role = LmsRole.STUDENT, passwordHash = passwordEncoder.encode(password) ?: "",
mustChangePassword = true, role = LmsRole.STUDENT,
tempPassword = password, mustChangePassword = true,
), tempPassword = password,
) ),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist. // Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student = students.save( val student =
Student( students.save(
studentNo = studentNo, Student(
lastName = request.lastName.trim(), studentNo = studentNo,
firstName = request.firstName.trim(), lastName = request.lastName.trim(),
middleName = request.middleName.trim(), firstName = request.firstName.trim(),
basis = request.basis.trim(), middleName = request.middleName.trim(),
note = request.note.trim(), basis = request.basis.trim(),
account = account, note = request.note.trim(),
), account = account,
) ),
)
enrollments.save(Enrollment(group = group, student = student)) enrollments.save(Enrollment(group = group, student = student))
return Credential( return Credential(
id = account.id.toString(), id = account.id.toString(),
@@ -85,13 +89,15 @@ class GroupService(
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet() val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
val currentByStudent = HashMap<UUID, Pair<UUID, String>>() val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
for (e in enrollments.findAll()) { enrollments
val sid = e.student?.accountId ?: continue .findAll()
val gid = e.group?.id ?: continue .mapNotNull { enrollment ->
val name = e.group?.name ?: continue val sid = enrollment.student?.accountId ?: return@mapNotNull null
currentByStudent[sid] = gid to name val group = enrollment.group ?: return@mapNotNull null
} sid to (group.id to group.name)
return students.findAll() }.forEach { (sid, group) -> currentByStudent[sid] = group }
return students
.findAll()
.filter { it.accountId !in memberIds } .filter { it.accountId !in memberIds }
.map { s -> .map { s ->
val current = s.accountId?.let { currentByStudent[it] } val current = s.accountId?.let { currentByStudent[it] }
@@ -102,16 +108,18 @@ class GroupService(
currentGroupId = current?.first?.toString(), currentGroupId = current?.first?.toString(),
currentGroupName = current?.second, currentGroupName = current?.second,
) )
} }.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
} }
/** Зачислить существующего студента; если он в другой группе — переводим. */ /** Зачислить существующего студента; если он в другой группе — переводим. */
@Transactional @Transactional
fun enroll(groupId: UUID, studentId: UUID): GroupDetail { fun enroll(
groupId: UUID,
studentId: UUID,
): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") } val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudent_AccountId(studentId) val existing = enrollments.findByStudentAccountId(studentId)
if (existing != null) { if (existing != null) {
if (existing.group?.id == group.id) { if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе") throw ConflictException("Студент уже в этой группе")
@@ -127,15 +135,23 @@ class GroupService(
/** Отчислить из группы (студент и его аккаунт сохраняются). */ /** Отчислить из группы (студент и его аккаунт сохраняются). */
@Transactional @Transactional
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail { fun unenroll(
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId } groupId: UUID,
?: throw NotFoundException("Студент не входит в эту группу") studentId: UUID,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment) enrollments.delete(enrollment)
return detail(groupId) return detail(groupId)
} }
@Transactional @Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult { fun import(
requestedName: String?,
filename: String,
bytes: ByteArray,
): ImportResult {
val parsed = parser.parse(filename, bytes) val parsed = parser.parse(filename, bytes)
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName)) val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
@@ -143,63 +159,80 @@ class GroupService(
val updated = mutableListOf<String>() val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent -> parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo) val student = upsertStudent(parsedStudent, added, updated)
val student = if (existing != null) { enrollIntoGroup(group, student)
existing.lastName = parsedStudent.lastName
existing.firstName = parsedStudent.firstName
existing.middleName = parsedStudent.middleName
existing.basis = parsedStudent.basis
existing.note = parsedStudent.note
students.save(existing)
updated.add(parsedStudent.studentNo)
existing
} else {
val password = Passwords.generate()
val account = accounts.save(
Account(
login = parsedStudent.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created = Student(
studentNo = parsedStudent.studentNo,
lastName = parsedStudent.lastName,
firstName = parsedStudent.firstName,
middleName = parsedStudent.middleName,
basis = parsedStudent.basis,
note = parsedStudent.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsedStudent.studentNo,
fullName = parsedStudent.fullName,
password = password,
),
)
created
}
val accountId = student.accountId!!
val enrollment = enrollments.findByStudent_AccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
} }
return ImportResult(group.id.toString(), group.name, added, updated) return ImportResult(group.id.toString(), group.name, added, updated)
} }
/** Обновляет существующего студента или создаёт нового с временным паролем. */
private fun upsertStudent(
parsed: ParsedStudent,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
students.save(existing)
updated.add(parsed.studentNo)
return existing
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = parsed.fullName,
password = password,
),
)
return created
}
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? { fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
return GroupSummary( return GroupSummary(
id = group.id.toString(), id = group.id.toString(),
name = group.name, name = group.name,
@@ -211,25 +244,30 @@ class GroupService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail { fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val rows = enrollments.findAllByGroupId(groupId).map { enrollment -> val rows =
val student = enrollment.student!! enrollments
val account = student.account!! .findAllByGroupId(groupId)
StudentRow( .map { enrollment ->
id = account.id.toString(), val student = enrollment.student!!
studentNo = student.studentNo, val account = student.account!!
fullName = student.fullName, StudentRow(
basis = student.basis, id = account.id.toString(),
mustChangePassword = account.mustChangePassword, studentNo = student.studentNo,
tempPassword = account.tempPassword, fullName = student.fullName,
) basis = student.basis,
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo })) mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows) return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
} }
@Transactional @Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse { fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student = students.findById(studentAccountId) val student =
.orElseThrow { NotFoundException("Студент не найден") } students
.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден") val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
val password = Passwords.generate() val password = Passwords.generate()
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
@@ -240,28 +278,34 @@ class GroupService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun credentials(groupId: UUID, format: String): ExportFile { fun credentials(
groupId: UUID,
format: String,
): ExportFile {
val detail = detail(groupId) val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_") val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
return when (format.lowercase()) { return when (format.lowercase()) {
"csv" -> ExportFile( "csv" ->
bytes = CredentialsExporter.csv(detail.students), ExportFile(
filename = "credentials_$safeName.csv", bytes = CredentialsExporter.csv(detail.students),
contentType = "text/csv; charset=utf-8", filename = "credentials_$safeName.csv",
) contentType = "text/csv; charset=utf-8",
"xlsx" -> ExportFile( )
bytes = CredentialsExporter.xlsx(detail.students), "xlsx" ->
filename = "credentials_$safeName.xlsx", ExportFile(
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes = CredentialsExporter.xlsx(detail.students),
) filename = "credentials_$safeName.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv") else -> throw BadRequestException("Формат должен быть xlsx или csv")
} }
} }
private fun summary(group: GroupEntity): GroupSummary = GroupSummary( private fun summary(group: GroupEntity): GroupSummary =
id = group.id.toString(), GroupSummary(
name = group.name, id = group.id.toString(),
studentCount = enrollments.countByGroup_Id(group.id), name = group.name,
taskDurationMin = group.taskDurationMin, studentCount = enrollments.countByGroupId(group.id),
) taskDurationMin = group.taskDurationMin,
)
} }

View File

@@ -10,9 +10,11 @@ import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/students") @RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class StudentController(private val groupService: GroupService) { class StudentController(
private val groupService: GroupService,
) {
@PostMapping("/{id}/reset-password") @PostMapping("/{id}/reset-password")
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse = fun resetPassword(
groupService.resetPassword(id) @PathVariable id: UUID,
): ResetPasswordResponse = groupService.resetPassword(id)
} }

View File

@@ -25,8 +25,10 @@ data class ParsedGroup(
@Component @Component
class StudentImportParser { class StudentImportParser {
fun parse(
fun parse(filename: String, bytes: ByteArray): ParsedGroup { filename: String,
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase() val lower = filename.lowercase()
return when { return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes)) lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
@@ -65,7 +67,10 @@ class StudentImportParser {
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';' return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
} }
private fun splitCsvLine(line: String, delimiter: Char): List<String> { private fun splitCsvLine(
line: String,
delimiter: Char,
): List<String> {
val result = mutableListOf<String>() val result = mutableListOf<String>()
val current = StringBuilder() val current = StringBuilder()
var inQuotes = false var inQuotes = false
@@ -83,51 +88,73 @@ class StudentImportParser {
return result return result
} }
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup { private fun parseRows(
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } } filename: String,
if (headerIndex < 0) { rows: List<List<String>>,
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.") ): ParsedGroup {
} val headerIndex = requireHeader(rows)
val columns = mapColumns(rows[headerIndex]) val columns = requireColumns(rows[headerIndex])
if (columns["studentNo"] == null) { val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
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"),
)
}
if (students.isEmpty()) { if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента") throw BadRequestException("В таблице не найдено ни одного студента")
} }
return ParsedGroup(groupNameFrom(filename, rows), students) return ParsedGroup(groupNameFrom(filename, rows), students)
} }
private fun requireHeader(rows: List<List<String>>): Int {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
return headerIndex
}
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
return columns
}
private fun parseStudent(
row: List<String>,
columns: Map<String, Int>,
): 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 { private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim() val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") || normalized.startsWith("студ.") || return normalized.startsWith("студен") ||
normalized.startsWith("зачёт") || normalized.startsWith("зачет") normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") ||
normalized.startsWith("зачет")
} }
private fun mapColumns(header: List<String>): Map<String, Int> { private fun mapColumns(header: List<String>): Map<String, Int> {
val result = mutableMapOf<String, Int>() val result = mutableMapOf<String, Int>()
header.forEachIndexed { index, raw -> header.forEachIndexed { index, raw ->
val value = raw.lowercase().replace("\u00a0", " ").trim() val value = raw.lowercase().replace("\u00a0", " ").trim()
fun putIfAbsent(key: String, vararg prefixes: String) {
fun putIfAbsent(
key: String,
vararg prefixes: String,
) {
if (key !in result && prefixes.any { value.startsWith(it) }) { if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index result[key] = index
} }
@@ -142,10 +169,12 @@ class StudentImportParser {
return result return result
} }
private fun normalizeNumber(value: String): String = private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
if (value.endsWith(".0")) value.dropLast(2) else value
private fun groupNameFrom(filename: String, rows: List<List<String>>): String { private fun groupNameFrom(
filename: String,
rows: List<List<String>>,
): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*") val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) { for (row in rows.take(12)) {
for (cell in row) { for (cell in row) {

View File

@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
@Configuration @Configuration
@EnableConfigurationProperties(JwtProperties::class) @EnableConfigurationProperties(JwtProperties::class)
class JwtConfig(private val keyStore: JwtKeyStore) { class JwtConfig(
private val keyStore: JwtKeyStore,
) {
@Bean
fun rsaKey(): RSAKey =
RSAKey
.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean @Bean
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey) fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
@Bean @Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build() fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()

View File

@@ -15,8 +15,9 @@ import java.util.Base64
* in the `settings` table, so no private key ever lives in the repository. * in the `settings` table, so no private key ever lives in the repository.
*/ */
@Service @Service
class JwtKeyStore(private val settings: SettingRepository) { class JwtKeyStore(
private val settings: SettingRepository,
) {
lateinit var privateKey: RSAPrivateKey lateinit var privateKey: RSAPrivateKey
lateinit var publicKey: RSAPublicKey lateinit var publicKey: RSAPublicKey
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
} }
} }
private fun toPem(label: String, der: ByteArray): String { private fun toPem(
label: String,
der: ByteArray,
): String {
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der) val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n" return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
} }

View File

@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
class SecurityConfig { class SecurityConfig {
@Bean @Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http http
@@ -26,19 +25,19 @@ class SecurityConfig {
.cors { it.disable() } .cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
auth.requestMatchers( auth
"/api/v1/auth/health", .requestMatchers(
"/api/v1/auth/setup", "/api/v1/auth/health",
"/api/v1/auth/login", "/api/v1/auth/setup",
"/api/v1/auth/jwks.json", "/api/v1/auth/login",
"/actuator/**", "/api/v1/auth/jwks.json",
"/v3/api-docs/**", "/actuator/**",
"/swagger-ui/**", "/v3/api-docs/**",
"/swagger-ui.html", "/swagger-ui/**",
).permitAll() "/swagger-ui.html",
).permitAll()
auth.anyRequest().authenticated() auth.anyRequest().authenticated()
} }.oauth2ResourceServer { resource ->
.oauth2ResourceServer { resource ->
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) } resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
} }
return http.build() return http.build()

View File

@@ -8,20 +8,20 @@ import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@SpringBootTest @SpringBootTest
@Testcontainers @Testcontainers
class IdentitySmokeTest { class IdentitySmokeTest {
companion object { companion object {
@Container @Container
val pg = PostgreSQLContainer("postgres:18").apply { val pg =
withDatabaseName("lms") PostgreSQLContainer("postgres:18").apply {
withUsername("lms") withDatabaseName("lms")
withPassword("lms") withUsername("lms")
} withPassword("lms")
}
@JvmStatic @JvmStatic
@DynamicPropertySource @DynamicPropertySource
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
@Test @Test
fun `setup creates admin and login works`() { fun `setup creates admin and login works`() {
val token = authService.setup( val token =
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345") authService.setup(
) ru.ysu.lms.identity.auth
.SetupRequest("admin", "test12345", "test12345"),
)
assert(token.token.isNotBlank()) { "token should not be blank" } assert(token.token.isNotBlank()) { "token should not be blank" }
assert(token.role == "ADMIN") assert(token.role == "ADMIN")
val login = authService.login( val login =
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345") authService.login(
) ru.ysu.lms.identity.auth
.LoginRequest("admin", "test12345"),
)
assert(login.token.isNotBlank()) assert(login.token.isNotBlank())
val ex = try { val ex =
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad")) try {
null authService.login(
} catch (e: UnauthorizedException) { e } ru.ysu.lms.identity.auth
.LoginRequest("admin", "bad"),
)
null
} catch (e: UnauthorizedException) {
e
}
assert(ex != null) { "bad password should throw" } assert(ex != null) { "bad password should throw" }
} }
} }

View File

@@ -23,4 +23,4 @@ dependencies {
springBoot { springBoot {
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt") mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
} }

View File

@@ -12,16 +12,28 @@ class AcademicClient(
) { ) {
private val client = RestClient.builder().baseUrl(baseUrl).build() private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getLab(labId: String, token: String): LabInfo = fun getLab(
client.get().uri("/api/v1/labs/{id}", labId) labId: String,
token: String,
): LabInfo =
client
.get()
.uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java) .retrieve()
.body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена") ?: throw NotFoundException("Лабораторная не найдена")
fun getLabSummary(labId: String, token: String): LabInfo = fun getLabSummary(
client.get().uri("/api/v1/labs/{id}", labId) labId: String,
token: String,
): LabInfo =
client
.get()
.uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java) .retrieve()
.body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена") ?: throw NotFoundException("Лабораторная не найдена")
} }
@@ -31,16 +43,25 @@ class IdentityClient(
) { ) {
private val client = RestClient.builder().baseUrl(baseUrl).build() private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getGroup(groupId: String, token: String): GroupInfo = fun getGroup(
client.get().uri("/api/v1/groups/{id}", groupId) groupId: String,
token: String,
): GroupInfo =
client
.get()
.uri("/api/v1/groups/{id}", groupId)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.retrieve().body(GroupInfo::class.java) .retrieve()
.body(GroupInfo::class.java)
?: throw NotFoundException("Группа не найдена") ?: throw NotFoundException("Группа не найдена")
fun listGroups(token: String): List<Map<String, Any>> { fun listGroups(token: String): List<Map<String, Any>> {
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {} val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
return client.get().uri("/api/v1/groups") return client
.get()
.uri("/api/v1/groups")
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.retrieve().body(type) ?: emptyList() .retrieve()
.body(type) ?: emptyList()
} }
} }

View File

@@ -1,10 +1,21 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.DeleteMapping
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String) data class DefenseAssignPayload(
val studentId: String,
val labId: String,
val assignmentMd: String,
)
data class DefenseAdminDto( data class DefenseAdminDto(
val id: String, val id: String,
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
@RestController @RestController
@RequestMapping("/api/v1/defenses") @RequestMapping("/api/v1/defenses")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
class DefenseAdminController(private val service: DefenseService) { class DefenseAdminController(
private val service: DefenseService,
) {
@PostMapping("/assign") @PostMapping("/assign")
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto = fun assign(
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd) @RequestBody body: DefenseAssignPayload,
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
@DeleteMapping("/assign") @DeleteMapping("/assign")
fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) { fun removeAssignment(
@RequestParam studentId: String,
@RequestParam labId: String,
) {
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId)) service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
} }
@GetMapping @GetMapping
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> = fun list(
@RequestParam labId: UUID,
): List<DefenseAdminDto> =
service.listForLab(labId).map { service.listForLab(labId).map {
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd, DefenseAdminDto(
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft) it.id,
it.studentId,
it.labId,
it.status,
it.code,
it.assignmentMd,
it.startedAt,
it.readyAt,
it.readyReason,
it.secondsLeft,
)
} }
@PostMapping("/{id}/issue-code") @PostMapping("/{id}/issue-code")
fun issueCode(@PathVariable id: UUID): Map<String, String> = mapOf("code" to service.issueCode(id)) fun issueCode(
@PathVariable id: UUID,
): Map<String, String> = mapOf("code" to service.issueCode(id))
@PostMapping("/{id}/reset") @PostMapping("/{id}/reset")
fun reset(@PathVariable id: UUID) = service.reset(id) fun reset(
@PathVariable id: UUID,
) = service.reset(id)
} }

View File

@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.* 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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/me/defenses") @RequestMapping("/api/v1/me/defenses")
class DefenseMyController(private val service: DefenseService) { class DefenseMyController(
private val service: DefenseService,
data class StartByCodePayload(val code: String) ) {
data class StartByCodePayload(
val code: String,
)
@GetMapping @GetMapping
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> = fun my(
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) } @AuthenticationPrincipal jwt: Jwt,
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
@GetMapping("/{labId}") @GetMapping("/{labId}")
fun one( fun one(
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) } ): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
private fun hideCode(d: DefenseDto): DefenseDto = private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
@PostMapping("/start") @PostMapping("/start")
fun start( fun start(

View File

@@ -31,7 +31,6 @@ data class DefenseDto(
class DefenseService( class DefenseService(
private val sessions: DefenseSessionRepository, private val sessions: DefenseSessionRepository,
) { ) {
companion object { companion object {
val DURATION: Duration = Duration.ofMinutes(30) val DURATION: Duration = Duration.ofMinutes(30)
private val random = SecureRandom() private val random = SecureRandom()
@@ -55,7 +54,11 @@ class DefenseService(
} }
@Transactional @Transactional
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto { fun assign(
studentId: UUID,
labId: UUID,
assignmentMd: String,
): DefenseDto {
val existing = sessions.findByStudentIdAndLabId(studentId, labId) val existing = sessions.findByStudentIdAndLabId(studentId, labId)
val s = existing ?: DefenseSession(studentId = studentId, labId = labId) val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
// смена задания сбрасывает активную попытку защиты // смена задания сбрасывает активную попытку защиты
@@ -106,19 +109,44 @@ class DefenseService(
} }
@Transactional @Transactional
fun startByCode(studentId: UUID, code: String): DefenseDto { fun startByCode(
studentId: UUID,
code: String,
): DefenseDto {
syncTimeouts() syncTimeouts()
val s = sessions.findByCode(code.trim()) val s = requireWaitingSession(studentId, code)
?: throw BadRequestException("Код не найден")
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
s.status = DefenseStatus.started.name s.status = DefenseStatus.started.name
s.startedAt = Instant.now() s.startedAt = Instant.now()
return toDto(sessions.save(s)) return toDto(sessions.save(s))
} }
private fun requireWaitingSession(
studentId: UUID,
code: String,
): DefenseSession {
val session = findSessionByCode(code)
requireOwnerAndWaiting(session, studentId)
return session
}
private fun findSessionByCode(code: String): DefenseSession =
sessions.findByCode(code.trim()) ?: throw BadRequestException("Код не найден")
private fun requireOwnerAndWaiting(
session: DefenseSession,
studentId: UUID,
) {
if (session.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (session.status != DefenseStatus.waiting.name) {
throw BadRequestException("Защита по этому коду уже начата")
}
}
@Transactional @Transactional
fun finish(studentId: UUID, sessionId: UUID): DefenseDto { fun finish(
studentId: UUID,
sessionId: UUID,
): DefenseDto {
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") } val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия") if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата") if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
@@ -135,13 +163,19 @@ class DefenseService(
} }
@Transactional @Transactional
fun mySession(studentId: UUID, labId: UUID): DefenseDto? { fun mySession(
studentId: UUID,
labId: UUID,
): DefenseDto? {
syncTimeouts() syncTimeouts()
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) } return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
} }
@Transactional @Transactional
fun resetByStudentLab(studentId: UUID, labId: UUID) { fun resetByStudentLab(
studentId: UUID,
labId: UUID,
) {
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
s.status = DefenseStatus.waiting.name s.status = DefenseStatus.waiting.name
s.code = null s.code = null
@@ -152,11 +186,18 @@ class DefenseService(
} }
private fun toDto(s: DefenseSession): DefenseDto { private fun toDto(s: DefenseSession): DefenseDto {
val left = if (s.status == DefenseStatus.started.name) { val left =
val started = s.startedAt ?: Instant.now() if (s.status == DefenseStatus.started.name) {
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0) val started = s.startedAt ?: Instant.now()
remain val remain =
} else null java.time.Duration
.between(Instant.now(), started.plus(DURATION))
.seconds
.coerceAtLeast(0)
remain
} else {
null
}
return DefenseDto( return DefenseDto(
id = s.id.toString(), id = s.id.toString(),
studentId = s.studentId.toString(), studentId = s.studentId.toString(),

View File

@@ -136,8 +136,15 @@ data class GradeSetRequest(
val comments: List<CommentInput> = emptyList(), val comments: List<CommentInput> = emptyList(),
) )
data class CommentInput(val text: String, val tone: String = "neutral") data class CommentInput(
data class CommentOut(val text: String, val tone: String) val text: String,
val tone: String = "neutral",
)
data class CommentOut(
val text: String,
val tone: String,
)
data class GradeResponse( data class GradeResponse(
val studentId: String, val studentId: String,
@@ -153,4 +160,4 @@ data class SubmissionDto(
val labId: String, val labId: String,
val createdAt: String, val createdAt: String,
val files: List<String>, val files: List<String>,
) )

View File

@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.progress.domain.GradePublication import ru.ysu.lms.progress.domain.GradePublication
import ru.ysu.lms.progress.domain.GradePublicationKey
import ru.ysu.lms.progress.repo.GradePublicationRepository import ru.ysu.lms.progress.repo.GradePublicationRepository
import java.util.UUID import java.util.UUID
@Service @Service
class GradePublicationService(private val repo: GradePublicationRepository) { class GradePublicationService(
private val repo: GradePublicationRepository,
) {
@Transactional @Transactional
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) { fun setPublished(
groupId: UUID,
labId: UUID,
published: Boolean,
) {
val existing = repo.findByLabIdAndGroupId(labId, groupId) val existing = repo.findByLabIdAndGroupId(labId, groupId)
if (published && existing == null) repo.save(GradePublication(groupId, labId)) if (published && existing == null) repo.save(GradePublication(groupId, labId))
if (!published && existing != null) repo.delete(existing) if (!published && existing != null) repo.delete(existing)
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isPublished(groupId: UUID, labId: UUID): Boolean = fun isPublished(
repo.findByLabIdAndGroupId(labId, groupId) != null groupId: UUID,
labId: UUID,
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun publishedGroupIdsForLab(labId: UUID): List<String> = fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
repo.findByLabId(labId).map { it.groupId.toString() }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun publishedLabIdsForGroup(groupId: UUID): List<String> = fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
repo.findByGroupId(groupId).map { it.labId.toString() }
} }

View File

@@ -0,0 +1,101 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import java.math.BigDecimal
import java.util.UUID
@Service
class GradeService(
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
) {
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
replaceComments(sid, lid, request.comments)
return GradeResponse(
request.studentId,
request.labId,
request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
)
}
private fun replaceComments(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) {
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
} else {
t.useCount += 1
t.tone = tone
templates.save(t)
}
}
}
private fun normalizeTone(tone: String): String =
when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true)
fun commentsOf(
sid: UUID,
lid: UUID,
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade =
gradeComments
.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
}

View File

@@ -1,29 +1,28 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.multipart.MultipartFile import org.springframework.web.bind.annotation.PostMapping
import java.net.URLEncoder import org.springframework.web.bind.annotation.RequestBody
import java.nio.charset.StandardCharsets import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
class ProgressController( class ProgressController(
private val service: ProgressService, private val service: ProgressService,
private val grades: GradeService,
private val publications: GradePublicationService, private val publications: GradePublicationService,
private val submissions: SubmissionService,
private val academic: AcademicClient,
) { ) {
@GetMapping("/me/assignments") @GetMapping("/me/assignments")
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> { fun meAssignments(
@AuthenticationPrincipal jwt: Jwt,
): List<MeAssignmentResponse> {
val sid = UUID.fromString(jwt.subject) val sid = UUID.fromString(jwt.subject)
return service.getStudentAssignments(sid).mapNotNull { va -> return service.getStudentAssignments(sid).mapNotNull { va ->
service.getStudentAssignment(sid, va.labId, jwt.tokenValue) service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
@@ -31,9 +30,7 @@ class ProgressController(
} }
@GetMapping("/me/labs") @GetMapping("/me/labs")
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> { fun meLabs(): List<Map<String, Any>> = listOf()
return listOf()
}
@PostMapping("/levels") @PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -44,7 +41,9 @@ class ProgressController(
@GetMapping("/levels") @GetMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> = fun getLevels(
@RequestParam labId: UUID,
): List<LevelAssignmentResponse> =
service.getLevelsForLab(labId).map { sll -> service.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse( LevelAssignmentResponse(
studentId = sll.studentId.toString(), studentId = sll.studentId.toString(),
@@ -77,84 +76,50 @@ class ProgressController(
@PostMapping("/grades") @PostMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body) fun setGrade(
@Valid @RequestBody body: GradeSetRequest,
): GradeResponse = grades.setGrade(body)
@GetMapping("/grades") @GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades() fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish") @PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) { fun publishGrades(
@RequestParam labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
@RequestParam published: Boolean,
) {
publications.setPublished(groupId, labId, published) publications.setPublished(groupId, labId, published)
} }
@GetMapping("/grades/published") @GetMapping("/grades/published")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean = fun gradesPublished(
publications.isPublished(groupId, labId) @RequestParam("labId") labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
): Boolean = publications.isPublished(groupId, labId)
@GetMapping("/grades/publications") @GetMapping("/grades/publications")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> = fun groupPublications(
publications.publishedLabIdsForGroup(groupId) @RequestParam groupId: java.util.UUID,
): List<String> = publications.publishedLabIdsForGroup(groupId)
@GetMapping("/me/grades") @GetMapping("/me/grades")
fun myGrades( fun myGrades(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?, @RequestParam(required = false) groupId: java.util.UUID?,
): List<GradeResponse> { ): List<GradeResponse> {
val all = service.listGrades().filter { it.studentId == jwt.subject } val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList() if (groupId == null) return emptyList()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) } return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
} }
@GetMapping("/comment-templates") @GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q) fun suggestComments(
@RequestParam q: String,
@PostMapping("/me/submissions/{labId}") ): List<CommentOut> = grades.suggestComments(q)
fun submit( }
@PathVariable labId: UUID,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
val sid = UUID.fromString(jwt.subject)
val all = submissions.submit(sid, labId, files, jwt.tokenValue)
return submissions.mySubmissions(sid, labId)
}
@GetMapping("/me/submissions")
fun mySubmissions(
@RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name)
}
@GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name)
}
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
}

View File

@@ -3,34 +3,25 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import ru.ysu.lms.progress.domain.VariantAssignment import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import java.math.BigDecimal
import java.util.UUID import java.util.UUID
@Service @Service
class ProgressService( class ProgressService(
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient, private val identity: IdentityClient,
) { ) {
@Transactional @Transactional
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse { fun setLevel(
request: LevelAssignmentRequest,
token: String,
): LevelAssignmentResponse {
val sid = UUID.fromString(request.studentId) val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId) val lid = UUID.fromString(request.labId)
val key = StudentLabLevelKey(sid, lid) val key = StudentLabLevelKey(sid, lid)
@@ -46,20 +37,27 @@ class ProgressService(
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId } val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
if (level != null && level.variants.isNotEmpty()) { if (level != null && level.variants.isNotEmpty()) {
val assignment = assignments.findByStudentIdAndLabId(sid, lid) val assignment = assignments.findByStudentIdAndLabId(sid, lid)
val currentInLevel = assignment?.variantId?.toString() val currentInLevel =
?.let { vid -> level.variants.any { it.id == vid } } == true assignment
?.variantId
?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
if (!currentInLevel) { if (!currentInLevel) {
val used = assignments.findByLabId(lid) val used =
.map { it.variantId.toString() } assignments
.filter { it != assignment?.variantId?.toString() } .findByLabId(lid)
.toMutableSet() .map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val pool = level.variants.map { it.id } val pool = level.variants.map { it.id }
val chosen = pool.filter { it !in used }.shuffled().firstOrNull() val chosen =
?: pool.shuffled().first() pool.filter { it !in used }.shuffled().firstOrNull()
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply { ?: pool.shuffled().first()
variantId = UUID.fromString(chosen) val target =
manual = false (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
} variantId = UUID.fromString(chosen)
manual = false
}
assignments.save(target) assignments.save(target)
} }
} }
@@ -72,98 +70,37 @@ class ProgressService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? = fun getLevel(
levels.findByStudentIdAndLabId(studentId, labId) studentId: UUID,
labId: UUID,
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
levels.findByLabId(labId)
@Transactional @Transactional
fun distribute(request: DistributeRequest, token: String): DistributeResult { fun distribute(
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) } request: DistributeRequest,
.map { it.trim() } token: String,
.filter { it.isNotBlank() } ): DistributeResult {
.distinct() val labIds = resolveLabIds(request)
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
val force = request.force == true val force = request.force == true
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet() val allStudents = collectStudentIds(request, token)
val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) {
studentIds.add(UUID.fromString(s.id))
}
}
val allStudents = studentIds.distinct()
var assignedTotal = 0 var assignedTotal = 0
var totalStudents = 0 var totalStudents = 0
var lastLabId: String? = null var lastLabId: String? = null
var lastLabTitle: String? = null var lastLabTitle: String? = null
for (labIdStr in labIds) { for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr) val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token) val lab = academic.getLab(labIdStr, token)
if (force) clearAutoAssignments(labId, allStudents)
// force сбрасывает авто-назначения только у выбранных студентов; val kept = keepExisting(labId, allStudents)
// закреплённые вручную варианты остаются как есть. assignedTotal += kept.size
if (force) { assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
assignments.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
val toAssign = allStudents.filterNot { kept.containsKey(it) }
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
// При этом очередь одна общая для всех выбранных групп: круг не
// «перезапускается» на каждой группе, а продолжается по наименее
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage = assignments.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
?: defaultLevel
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assignedTotal++
}
}
totalStudents += allStudents.size totalStudents += allStudents.size
lastLabId = labIdStr lastLabId = labIdStr
lastLabTitle = lab.title lastLabTitle = lab.title
} }
return DistributeResult( return DistributeResult(
labs = labIds.size, labs = labIds.size,
assigned = assignedTotal, assigned = assignedTotal,
@@ -173,24 +110,121 @@ class ProgressService(
) )
} }
private fun resolveLabIds(request: DistributeRequest): List<String> {
val labIds =
request.labIds
.orEmpty()
.ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
return labIds
}
private fun collectStudentIds(
request: DistributeRequest,
token: String,
): List<UUID> {
val groupFilter =
request.groupIds
.orEmpty()
.filter { it.isNotBlank() }
.toSet()
val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
}
return studentIds.distinct()
}
private fun clearAutoAssignments(
labId: UUID,
allStudents: List<UUID>,
) {
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
private fun keepExisting(
labId: UUID,
allStudents: List<UUID>,
): Map<UUID, VariantAssignment> {
val kept = mutableMapOf<UUID, VariantAssignment>()
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
return kept
}
/** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
private fun assignNew(
lab: LabInfo,
labId: UUID,
toAssign: List<UUID>,
): Int {
val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
val usage =
assignments
.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
var assigned = 0
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
val pool = pickPool(levelId, poolsByLevel, defaultLevel)
if (pool.isEmpty()) continue
val minCount = pool.minOf { id -> usage[id] ?: 0 }
val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assigned++
}
return assigned
}
private fun pickPool(
levelId: String?,
poolsByLevel: Map<String, List<String>>,
defaultLevel: String?,
): List<String> =
levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
assignments.findByLabId(labId)
@Transactional @Transactional
fun assign(request: AssignRequest): VariantAssignment { fun assign(request: AssignRequest): VariantAssignment {
val sid = UUID.fromString(request.studentId) val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId) val lid = UUID.fromString(request.labId)
val existing = assignments.findByStudentIdAndLabId(sid, lid) val existing = assignments.findByStudentIdAndLabId(sid, lid)
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply { val entity =
variantId = UUID.fromString(request.variantId) (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
manual = true variantId = UUID.fromString(request.variantId)
} manual = true
}
return assignments.save(entity) return assignments.save(entity)
} }
@Transactional @Transactional
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse { fun assignWithInfo(
request: AssignRequest,
token: String,
): VariantAssignmentResponse {
assign(request) assign(request)
val lab = academic.getLab(request.labId, token) val lab = academic.getLab(request.labId, token)
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId } val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
@@ -204,7 +238,10 @@ class ProgressService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> { fun assignmentsForLabEnriched(
labId: UUID,
token: String,
): List<VariantAssignmentResponse> {
val lab = academic.getLab(labId.toString(), token) val lab = academic.getLab(labId.toString(), token)
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id } val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
return assignments.findByLabId(labId).map { va -> return assignments.findByLabId(labId).map { va ->
@@ -220,76 +257,22 @@ class ProgressService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
assignments.findByStudentId(studentId)
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
replaceComments(sid, lid, request.comments)
return GradeResponse(request.studentId, request.labId, request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
}
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
else { t.useCount += 1; t.tone = tone; templates.save(t) }
}
}
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid) fun getStudentAssignment(
studentId: UUID,
@Transactional(readOnly = true) labId: UUID,
fun suggestComments(query: String): List<CommentOut> = token: String,
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) ): MeAssignmentResponse? {
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade = gradeComments.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
@Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
return enrich(va, token) return enrich(va, token)
} }
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? { private fun enrich(
va: VariantAssignment,
token: String,
): MeAssignmentResponse? {
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() } val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } } val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
@@ -306,4 +289,4 @@ class ProgressService(
pointsMax = lab.pointsMax, pointsMax = lab.pointsMax,
) )
} }
} }

View File

@@ -2,11 +2,10 @@ package ru.ysu.lms.progress
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
@SpringBootApplication @SpringBootApplication
class ProgressServiceApplication class ProgressServiceApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {
runApplication<ProgressServiceApplication>(*args) runApplication<ProgressServiceApplication>(*args)
} }

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.progress
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
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.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class SubmissionController(
private val submissions: SubmissionService,
) {
@PostMapping("/me/submissions/{labId}")
fun submit(
@PathVariable labId: UUID,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
val sid = UUID.fromString(jwt.subject)
submissions.submit(sid, labId, files, jwt.tokenValue)
return submissions.mySubmissions(sid, labId)
}
@GetMapping("/me/submissions")
fun mySubmissions(
@RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name)
}
@GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(
@RequestParam labId: UUID,
): List<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name)
}
private fun attachment(
bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
}

View File

@@ -18,7 +18,6 @@ class SubmissionService(
private val storage: SubmissionStorage, private val storage: SubmissionStorage,
private val academic: AcademicClient, private val academic: AcademicClient,
) { ) {
@Transactional @Transactional
fun submit( fun submit(
studentId: UUID, studentId: UUID,
@@ -26,30 +25,71 @@ class SubmissionService(
files: List<MultipartFile>, files: List<MultipartFile>,
token: String, token: String,
): SubmissionDto { ): SubmissionDto {
if (files.isEmpty() || files.all { it.isEmpty }) { val payload = validatedPayload(files)
throw BadRequestException("Приложите файлы или архив") ensureDeadline(requireLab(labId, token))
} val seq = nextSeq(studentId, labId)
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
}
val last = submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
val seq = (last?.seq ?: 0) + 1
val payload = files.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
val stored = storage.store(studentId, labId, seq, payload) val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq)) val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
return toDto(entity, stored.files) return toDto(entity, stored.files)
} }
private fun validatedPayload(files: List<MultipartFile>): List<Pair<String, ByteArray>> {
if (files.isEmpty() || files.all { it.isEmpty }) {
throw BadRequestException("Приложите файлы или архив")
}
return files
.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
.onEach { (name, bytes) -> rejectProjectFile(name, bytes) }
}
private fun rejectProjectFile(
name: String,
bytes: ByteArray,
) {
if (looksLikeProjectFile(name, bytes)) {
throw BadRequestException(
"Файл «$name» похож на файл проекта (XML), а не на исходник. " +
"Приложите файл .c/.h или zip с проектом.",
)
}
}
private fun requireLab(
labId: UUID,
token: String,
): LabInfo =
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
private fun ensureDeadline(lab: LabInfo) {
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
}
}
private fun nextSeq(
studentId: UUID,
labId: UUID,
): Int = (submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)?.seq ?: 0) + 1
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> = fun mySubmissions(
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) } studentId: UUID,
labId: UUID,
): List<SubmissionDto> =
submissions
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
.map { it to filesOf(it) }
.map { (e, fs) -> toDto(e, fs) } .map { (e, fs) -> toDto(e, fs) }
@Transactional @Transactional
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> { fun download(
studentId: UUID,
submissionId: UUID,
callerStudentId: UUID?,
isAdmin: Boolean,
): Pair<ByteArray, String> {
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") } val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) { if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
throw ForbiddenException("Нет доступа к этой сдаче") throw ForbiddenException("Нет доступа к этой сдаче")
@@ -70,10 +110,32 @@ class SubmissionService(
return perStudent.values.map { toDto(it, filesOf(it)) } return perStudent.values.map { toDto(it, filesOf(it)) }
} }
private fun filesOf(s: Submission): List<String> = /** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq)) private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
private fun toDto(s: Submission, files: List<String>): SubmissionDto = private fun looksLikeProjectFile(
name: String,
bytes: ByteArray,
): Boolean {
val ext = name.substringAfterLast('.', "").lowercase()
if (ext !in sourceExtensions) return false
val head =
String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
.removePrefix("\uFEFF")
.trimStart()
.lowercase()
return head.startsWith("<?xml") ||
head.startsWith("<solution") ||
head.startsWith("<project") ||
head.startsWith("<configurations")
}
private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
private fun toDto(
s: Submission,
files: List<String>,
): SubmissionDto =
SubmissionDto( SubmissionDto(
id = s.id.toString(), id = s.id.toString(),
seq = s.seq, seq = s.seq,

View File

@@ -9,14 +9,29 @@ import java.util.UUID
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
data class StoredSubmission(val dir: Path, val files: List<String>) data class StoredSubmission(
val dir: Path,
val files: List<String>,
)
@Component @Component
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) { class SubmissionStorage(
@Value("\${lms.submissions.dir:data/submissions}") root: String,
) {
private val rootDir: Path = Path.of(root) private val rootDir: Path = Path.of(root)
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission { fun store(
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize() studentId: UUID,
labId: UUID,
seq: Int,
files: List<Pair<String, ByteArray>>,
): StoredSubmission {
val dir =
rootDir
.resolve(studentId.toString())
.resolve(labId.toString())
.resolve(seq.toString())
.normalize()
require(dir.startsWith(rootDir)) { "path escape blocked" } require(dir.startsWith(rootDir)) { "path escape blocked" }
Files.createDirectories(dir) Files.createDirectories(dir)
val names = mutableListOf<String>() val names = mutableListOf<String>()
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
fun listFiles(storedDir: Path): List<String> = fun listFiles(storedDir: Path): List<String> =
if (Files.isDirectory(storedDir)) { if (Files.isDirectory(storedDir)) {
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList() Files
.list(storedDir)
.map { it.fileName.toString() }
.sorted()
.toList()
} else { } else {
emptyList() emptyList()
} }
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path = fun dirFor(
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()) studentId: UUID,
labId: UUID,
seq: Int,
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
private fun sanitize(name: String): String { private fun sanitize(name: String): String {
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" } val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }

View File

@@ -20,4 +20,4 @@ class DefenseSession(
@Column(name = "started_at") var startedAt: Instant? = null, @Column(name = "started_at") var startedAt: Instant? = null,
@Column(name = "ready_at") var readyAt: Instant? = null, @Column(name = "ready_at") var readyAt: Instant? = null,
@Column(name = "ready_reason") var readyReason: String? = null, @Column(name = "ready_reason") var readyReason: String? = null,
) )

View File

@@ -21,4 +21,8 @@ class GradePublication(
data class GradePublicationKey( data class GradePublicationKey(
val groupId: UUID = UUID(0, 0), val groupId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0), val labId: UUID = UUID(0, 0),
) : Serializable ) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -23,4 +23,8 @@ class LabGrade(
data class LabGradeKey( data class LabGradeKey(
val studentId: UUID = UUID(0, 0), val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0), val labId: UUID = UUID(0, 0),
) : Serializable ) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -11,13 +11,13 @@ import java.util.UUID
@Table(name = "student_lab_levels") @Table(name = "student_lab_levels")
@IdClass(StudentLabLevelKey::class) @IdClass(StudentLabLevelKey::class)
class StudentLabLevel( class StudentLabLevel(
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0), @Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
@Id @Column(nullable = false) var labId: UUID = UUID(0,0), @Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0), @Column(nullable = false) var labLevelId: UUID = UUID(0, 0),
@Column(nullable = false) var manual: Boolean = false, @Column(nullable = false) var manual: Boolean = false,
) )
data class StudentLabLevelKey( data class StudentLabLevelKey(
val studentId: UUID = UUID(0,0), val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0,0), val labId: UUID = UUID(0, 0),
) : java.io.Serializable ) : java.io.Serializable

View File

@@ -16,4 +16,4 @@ class Submission(
@Column(nullable = false) var labId: UUID = UUID(0, 0), @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var seq: Int = 1, @Column(nullable = false) var seq: Int = 1,
@Column(nullable = false) var createdAt: Instant = Instant.now(), @Column(nullable = false) var createdAt: Instant = Instant.now(),
) )

View File

@@ -12,9 +12,9 @@ import java.util.UUID
@Table(name = "variant_assignments") @Table(name = "variant_assignments")
class VariantAssignment( class VariantAssignment(
@Id var id: UUID = Ids.v7(), @Id var id: UUID = Ids.v7(),
@Column(nullable = false) var studentId: UUID = UUID(0,0), @Column(nullable = false) var studentId: UUID = UUID(0, 0),
@Column(nullable = false) var labId: UUID = UUID(0,0), @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var variantId: UUID = UUID(0,0), @Column(nullable = false) var variantId: UUID = UUID(0, 0),
@Column(nullable = false) var manual: Boolean = false, @Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(), @Column(nullable = false) var assignedAt: Instant = Instant.now(),
) )

View File

@@ -7,11 +7,20 @@ import java.util.UUID
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> { interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
fun findByText(text: String): CommentTemplate? fun findByText(text: String): CommentTemplate?
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate> fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
} }
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> { interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment> fun findByStudentIdAndLabId(
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID) studentId: UUID,
labId: UUID,
): List<GradeComment>
fun deleteByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
)
fun findByStudentId(studentId: UUID): List<GradeComment> fun findByStudentId(studentId: UUID): List<GradeComment>
} }

View File

@@ -6,8 +6,15 @@ import java.util.UUID
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> { interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession> fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): DefenseSession?
fun findByStudentId(studentId: UUID): List<DefenseSession> fun findByStudentId(studentId: UUID): List<DefenseSession>
fun findByStatus(status: String): List<DefenseSession> fun findByStatus(status: String): List<DefenseSession>
fun findByCode(code: String): DefenseSession? fun findByCode(code: String): DefenseSession?
} }

View File

@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
import java.util.UUID import java.util.UUID
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> { interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication? fun findByLabIdAndGroupId(
labId: UUID,
groupId: UUID,
): GradePublication?
fun findByLabId(labId: UUID): List<GradePublication> fun findByLabId(labId: UUID): List<GradePublication>
fun findByGroupId(groupId: UUID): List<GradePublication> fun findByGroupId(groupId: UUID): List<GradePublication>
} }

View File

@@ -7,6 +7,11 @@ import java.util.UUID
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> { interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
fun findAllBy(): List<LabGrade> fun findAllBy(): List<LabGrade>
fun findByStudentId(studentId: UUID): List<LabGrade> fun findByStudentId(studentId: UUID): List<LabGrade>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): LabGrade?
} }

View File

@@ -7,7 +7,13 @@ import java.util.UUID
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> { interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
fun findByLabId(labId: UUID): List<StudentLabLevel> fun findByLabId(labId: UUID): List<StudentLabLevel>
fun findByStudentId(studentId: UUID): List<StudentLabLevel> fun findByStudentId(studentId: UUID): List<StudentLabLevel>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): StudentLabLevel?
fun deleteByLabId(labId: UUID) fun deleteByLabId(labId: UUID)
} }

View File

@@ -6,7 +6,16 @@ import java.util.UUID
interface SubmissionRepository : JpaRepository<Submission, UUID> { interface SubmissionRepository : JpaRepository<Submission, UUID> {
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission> fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
fun findByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,
): List<Submission>
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission> fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
} fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,
): Submission?
}

View File

@@ -6,7 +6,13 @@ import java.util.UUID
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> { interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
fun findByStudentId(studentId: UUID): List<VariantAssignment> fun findByStudentId(studentId: UUID): List<VariantAssignment>
fun findByLabId(labId: UUID): List<VariantAssignment> fun findByLabId(labId: UUID): List<VariantAssignment>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): VariantAssignment?
fun deleteByLabId(labId: UUID) fun deleteByLabId(labId: UUID)
} }

View File

@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtDecoder
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
class SecurityConfig { class SecurityConfig {
@Bean @Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build() fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@@ -29,8 +27,11 @@ class SecurityConfig {
val converter = JwtAuthenticationConverter() val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt -> converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role") val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList() if (role.isNullOrBlank()) {
else listOf(SimpleGrantedAuthority("ROLE_$role")) emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
} }
return converter return converter
} }
@@ -44,8 +45,7 @@ class SecurityConfig {
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated() auth.anyRequest().authenticated()
} }.oauth2ResourceServer { oauth ->
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt -> oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
} }
@@ -56,4 +56,4 @@ class SecurityConfig {
companion object { companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json" private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
} }
} }

View File

@@ -0,0 +1,82 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.util.UUID
/**
* Интеграционный тест распределения вариантов: реальная PostgreSQL (Testcontainers) + Flyway,
* внешние сервисы (identity/academic) подменены заглушками.
*/
@Testcontainers
@SpringBootTest
class ProgressDistributionIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var service: ProgressService
@Autowired
private lateinit var assignments: VariantAssignmentRepository
@MockitoBean
private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@Test
fun `distributes variants to every student of the selected group`() {
val labId = UUID.randomUUID()
val variantA = UUID.randomUUID().toString()
val variantB = UUID.randomUUID().toString()
val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo(
id = labId.toString(),
levels =
listOf(
LevelInfo(
id = "lvl",
code = "basic",
title = "Основная",
variants =
listOf(
VariantInfo(id = variantA, number = "1"),
VariantInfo(id = variantB, number = "2"),
),
),
),
),
)
val result = service.distribute(DistributeRequest(labIds = listOf(labId.toString()), groupIds = listOf("g1")), "t")
assertThat(result.assigned).isEqualTo(2)
assertThat(result.labs).isEqualTo(1)
val saved = assignments.findByLabId(labId)
assertThat(saved).hasSize(2)
assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2)
assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB)
}
}

View File

@@ -0,0 +1,87 @@
package ru.ysu.lms.progress
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Path
import java.util.UUID
import java.util.zip.ZipInputStream
class SubmissionServiceTest {
@TempDir
lateinit var tempDir: Path
private val studentId = UUID.randomUUID()
private val labId = UUID.randomUUID()
private fun service(): Pair<SubmissionService, SubmissionRepository> {
val repo = mockk<SubmissionRepository>()
val academic = mockk<AcademicClient>()
var saved: Submission? = null
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
every { repo.save(any()) } answers {
firstArg<Submission>().also { saved = it }
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
val storage = SubmissionStorage(tempDir.toString())
return SubmissionService(repo, storage, academic) to repo
}
@Test
fun `stores submitted source file and returns it in zip`() {
val (service, _) = service()
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
val dto = service.submit(studentId, labId, listOf(file), "token")
assertThat(dto.files).containsExactly("main.c")
assertThat(dto.seq).isEqualTo(1)
val (bytes, name) = service.download(studentId, UUID.fromString(dto.id), studentId, isAdmin = false)
assertThat(name).endsWith(".zip")
val entries = zipEntries(bytes)
assertThat(entries.keys).containsExactly("main.c")
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
}
@Test
fun `rejects xml project file renamed to source extension`() {
val (service, _) = service()
val xml = "<Solution>\n <Configurations/>\n</Solution>".toByteArray()
val file = MockMultipartFile("files", "ConsoleApplication1.C", "text/plain", xml)
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("файл проекта")
}
@Test
fun `allows zip archive`() {
val (service, _) = service()
val file = MockMultipartFile("files", "project.zip", "application/zip", byteArrayOf(0x50, 0x4B, 0x03, 0x04))
val dto = service.submit(studentId, labId, listOf(file), "token")
assertThat(dto.files).containsExactly("project.zip")
}
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
val result = linkedMapOf<String, ByteArray>()
ZipInputStream(bytes.inputStream()).use { zin ->
var e = zin.nextEntry
while (e != null) {
if (!e.isDirectory) result[e.name] = zin.readAllBytes()
e = zin.nextEntry
}
}
return result
}
}

View File

@@ -12,18 +12,17 @@ object DateUtils {
private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
/** ISO instant string -> "02.11.2026 23:59" в Москве. */ /** ISO instant string -> "02.11.2026 23:59" в Москве. */
fun formatMsk(value: String?): String? = fun formatMsk(value: String?): String? = toZoned(value)?.format(FORMAT)
toZoned(value)?.format(FORMAT)
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */ /** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
fun localInput(value: String?): String? = fun localInput(value: String?): String? = toZoned(value)?.format(INPUT_FORMAT) ?: ""
toZoned(value)?.format(INPUT_FORMAT) ?: ""
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */ /** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
fun toUtcIso(localValue: String?): String? = fun toUtcIso(localValue: String?): String? =
localValue?.takeIf { it.isNotBlank() }?.let { s -> localValue?.takeIf { it.isNotBlank() }?.let { s ->
try { try {
LocalDateTime.parse(s, INPUT_FORMAT) LocalDateTime
.parse(s, INPUT_FORMAT)
.atZone(MSK) .atZone(MSK)
.toInstant() .toInstant()
.toString() .toString()
@@ -38,4 +37,4 @@ object DateUtils {
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
} }

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More