Compare commits
17 Commits
d81cebef70
...
debf157194
| Author | SHA1 | Date | |
|---|---|---|---|
| debf157194 | |||
| 6129055962 | |||
| 4035bc5a0f | |||
| 290b2a5a20 | |||
| 3ed48b35fc | |||
| f08ab16af0 | |||
| c22eb15d3e | |||
| 6e891e4326 | |||
| 47ec9884a3 | |||
| a87110580b | |||
| 4a8a10971c | |||
| 9a2a4023b5 | |||
| 511df7977e | |||
| b7d96d2ea4 | |||
| f3e8b2668f | |||
| 802616918f | |||
| a8785d48d9 |
14
.editorconfig
Normal file
14
.editorconfig
Normal 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
|
||||
14
README.md
14
README.md
@@ -48,6 +48,20 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
|
||||
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
|
||||
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 (для сервера)
|
||||
|
||||
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
||||
|
||||
@@ -13,4 +13,6 @@ dependencies {
|
||||
implementation(libs.kotlinAllopenPlugin)
|
||||
implementation(libs.kotlinNoargPlugin)
|
||||
implementation(libs.springBootGradlePlugin)
|
||||
implementation(libs.ktlintGradlePlugin)
|
||||
implementation(libs.detektGradlePlugin)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ dependencyResolutionManagement {
|
||||
@Suppress("UnstableApiUsage")
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
// Reuse the version catalog from the main build.
|
||||
|
||||
34
buildSrc/src/main/kotlin/lms.lint.gradle.kts
Normal file
34
buildSrc/src/main/kotlin/lms.lint.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.spring")
|
||||
id("org.jetbrains.kotlin.plugin.jpa")
|
||||
id("org.springframework.boot")
|
||||
id("lms.lint")
|
||||
}
|
||||
|
||||
group = "ru.ysu.lms"
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("org.jetbrains.kotlin.plugin.spring")
|
||||
id("lms.lint")
|
||||
}
|
||||
|
||||
group = "ru.ysu.lms"
|
||||
|
||||
71
config/detekt/detekt.yml
Normal file
71
config/detekt/detekt.yml
Normal 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|_'
|
||||
12
gradle/gradle-daemon-jvm.properties
Normal file
12
gradle/gradle-daemon-jvm.properties
Normal 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
|
||||
@@ -10,6 +10,9 @@ springdoc = "3.0.3"
|
||||
flexmark = "0.64.8"
|
||||
owaspSanitizer = "20260313.1"
|
||||
apachePoi = "5.5.1"
|
||||
ktlint = "14.2.0"
|
||||
detekt = "1.23.8"
|
||||
mockk = "1.14.11"
|
||||
|
||||
[libraries]
|
||||
# --- 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" }
|
||||
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
|
||||
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) ---
|
||||
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" }
|
||||
|
||||
# --- 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" }
|
||||
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
|
||||
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" }
|
||||
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
|
||||
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" }
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean
|
||||
|
||||
@AutoConfiguration
|
||||
class LmsCommonAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
|
||||
|
||||
@@ -13,17 +13,27 @@ open class LmsException(
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
) : RuntimeException(message)
|
||||
|
||||
class BadRequestException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.BAD_REQUEST, message, code)
|
||||
class BadRequestException(
|
||||
message: String,
|
||||
code: String? = null,
|
||||
) : LmsException(HttpStatus.BAD_REQUEST, message, code)
|
||||
|
||||
class NotFoundException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.NOT_FOUND, message, code)
|
||||
class NotFoundException(
|
||||
message: String,
|
||||
code: String? = null,
|
||||
) : LmsException(HttpStatus.NOT_FOUND, message, code)
|
||||
|
||||
class ConflictException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.CONFLICT, message, code)
|
||||
class ConflictException(
|
||||
message: String,
|
||||
code: String? = null,
|
||||
) : LmsException(HttpStatus.CONFLICT, message, code)
|
||||
|
||||
class ForbiddenException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.FORBIDDEN, message, code)
|
||||
class ForbiddenException(
|
||||
message: String,
|
||||
code: String? = null,
|
||||
) : LmsException(HttpStatus.FORBIDDEN, message, code)
|
||||
|
||||
class UnauthorizedException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
||||
class UnauthorizedException(
|
||||
message: String,
|
||||
code: String? = null,
|
||||
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
@RestControllerAdvice
|
||||
class LmsExceptionHandler {
|
||||
|
||||
@ExceptionHandler(LmsException::class)
|
||||
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
|
||||
ResponseEntity.status(ex.status).body(
|
||||
@@ -22,9 +21,10 @@ class LmsExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
|
||||
val violations = ex.bindingResult.fieldErrors.map {
|
||||
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
||||
}
|
||||
val violations =
|
||||
ex.bindingResult.fieldErrors.map {
|
||||
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
ApiError(
|
||||
title = "Validation failed",
|
||||
|
||||
@@ -9,4 +9,6 @@ dependencies {
|
||||
api(libs.owaspHtmlSanitizer)
|
||||
api(libs.springContext)
|
||||
api(libs.springBootAutoconfigure)
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(MarkdownProperties::class)
|
||||
class LmsMarkdownAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
|
||||
MarkdownRenderer(allowHtml = properties.allowHtml)
|
||||
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
|
||||
}
|
||||
|
||||
@@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory
|
||||
class MarkdownRenderer(
|
||||
private val allowHtml: Boolean = false,
|
||||
) {
|
||||
private val options = MutableDataSet().apply {
|
||||
set(
|
||||
Parser.EXTENSIONS,
|
||||
listOf(
|
||||
TablesExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create(),
|
||||
FootnoteExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
),
|
||||
)
|
||||
set(HtmlRenderer.GENERATE_HEADER_ID, true)
|
||||
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
|
||||
}
|
||||
private val options =
|
||||
MutableDataSet().apply {
|
||||
set(
|
||||
Parser.EXTENSIONS,
|
||||
listOf(
|
||||
TablesExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create(),
|
||||
FootnoteExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
),
|
||||
)
|
||||
set(HtmlRenderer.GENERATE_HEADER_ID, true)
|
||||
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
|
||||
}
|
||||
|
||||
private val parser: Parser = Parser.builder(options).build()
|
||||
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
|
||||
@@ -55,21 +56,26 @@ class MarkdownRenderer(
|
||||
|
||||
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
|
||||
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>")
|
||||
|
||||
private fun renderRaw(markdown: String, depth: Int): String {
|
||||
private fun renderRaw(
|
||||
markdown: String,
|
||||
depth: Int,
|
||||
): String {
|
||||
val out = StringBuilder()
|
||||
var cursor = 0
|
||||
for (match in SPOILER.findAll(markdown)) {
|
||||
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
|
||||
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
|
||||
val body = match.groupValues[2]
|
||||
val inner = if (depth < MAX_DEPTH) {
|
||||
renderRaw(body, depth + 1)
|
||||
} else {
|
||||
renderWithDirectives(body)
|
||||
}
|
||||
val inner =
|
||||
if (depth < MAX_DEPTH) {
|
||||
renderRaw(body, depth + 1)
|
||||
} else {
|
||||
renderWithDirectives(body)
|
||||
}
|
||||
out.append("<details class=\"spoiler\"><summary>")
|
||||
out.append(escapeHtml(title))
|
||||
out.append("</summary><div class=\"spoiler-body\">")
|
||||
@@ -81,8 +87,7 @@ class MarkdownRenderer(
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderSegment(markdown: String): String =
|
||||
renderer.render(parser.parse(markdown))
|
||||
private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
|
||||
|
||||
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
|
||||
private fun renderWithDirectives(markdown: String): String {
|
||||
@@ -118,51 +123,114 @@ class MarkdownRenderer(
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderInline(text: String): String =
|
||||
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||
private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||
|
||||
private fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
private fun escapeHtml(value: String): String =
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
|
||||
.allowElements(
|
||||
"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",
|
||||
)
|
||||
.allowAttributes("href").onElements("a")
|
||||
.allowAttributes("title").onElements("a", "img")
|
||||
.allowAttributes("src", "alt").onElements("img")
|
||||
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||
.onElements("video")
|
||||
.allowAttributes("src", "type").onElements("source")
|
||||
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
|
||||
.allowAttributes("class").onElements(
|
||||
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
|
||||
"figure", "figcaption", "video", "img",
|
||||
)
|
||||
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
.allowAttributes("colspan", "rowspan").onElements("td", "th")
|
||||
.allowUrlProtocols("http", "https", "mailto")
|
||||
.requireRelNofollowOnLinks()
|
||||
.toFactory()
|
||||
private fun buildSanitizer(): PolicyFactory =
|
||||
HtmlPolicyBuilder()
|
||||
.allowElements(*ALLOWED_ELEMENTS)
|
||||
.allowAttributes("href")
|
||||
.onElements("a")
|
||||
.allowAttributes("title")
|
||||
.onElements("a", "img")
|
||||
.allowAttributes("src", "alt")
|
||||
.onElements("img")
|
||||
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||
.onElements("video")
|
||||
.allowAttributes("src", "type")
|
||||
.onElements("source")
|
||||
.allowAttributes("src", "kind", "label", "srclang", "default")
|
||||
.onElements("track")
|
||||
.allowAttributes("class")
|
||||
.onElements(*CLASS_ATTR_ELEMENTS)
|
||||
.allowAttributes("id")
|
||||
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
.allowAttributes("colspan", "rowspan")
|
||||
.onElements("td", "th")
|
||||
.allowUrlProtocols("http", "https", "mailto")
|
||||
.requireRelNofollowOnLinks()
|
||||
.toFactory()
|
||||
|
||||
private companion object {
|
||||
const val MAX_DEPTH = 5
|
||||
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]*$",
|
||||
)
|
||||
|
||||
/** Теги, разрешённые в отрендеренном HTML (allow-list санитайзера). */
|
||||
val ALLOWED_ELEMENTS =
|
||||
arrayOf(
|
||||
"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]*$",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<script>")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renders spoiler container`() {
|
||||
val html = renderer.render(":::spoiler Ответ\nскрытый текст\n:::")
|
||||
|
||||
assertThat(html).contains("spoiler")
|
||||
assertThat(html).contains("скрытый текст")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ package ru.ysu.lms.security
|
||||
|
||||
enum class LmsRole {
|
||||
ADMIN,
|
||||
STUDENT;
|
||||
STUDENT,
|
||||
;
|
||||
|
||||
fun authority(): String = "ROLE_$name"
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class LmsSecurityAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
|
||||
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
|
||||
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
|
||||
.privateKey(RsaKeys.privateKey(properties.privateKey))
|
||||
.build()
|
||||
val rsaKey =
|
||||
RSAKey
|
||||
.Builder(RsaKeys.publicKey(properties.publicKey))
|
||||
.privateKey(RsaKeys.privateKey(properties.privateKey))
|
||||
.build()
|
||||
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,24 +9,26 @@ import java.util.Base64
|
||||
|
||||
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
|
||||
object RsaKeys {
|
||||
|
||||
fun publicKey(pem: String): RSAPublicKey {
|
||||
val der = decode(pem)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
return KeyFactory
|
||||
.getInstance("RSA")
|
||||
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
|
||||
}
|
||||
|
||||
fun privateKey(pem: String): RSAPrivateKey {
|
||||
val der = decode(pem)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
return KeyFactory
|
||||
.getInstance("RSA")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
|
||||
}
|
||||
|
||||
private fun decode(pem: String): ByteArray {
|
||||
val body = pem
|
||||
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
|
||||
.replace(Regex("-----END [A-Z ]+-----"), "")
|
||||
.replace(Regex("\\s"), "")
|
||||
val body =
|
||||
pem
|
||||
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
|
||||
.replace(Regex("-----END [A-Z ]+-----"), "")
|
||||
.replace(Regex("\\s"), "")
|
||||
return Base64.getDecoder().decode(body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
api(platform(libs.springBootBom))
|
||||
|
||||
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
|
||||
api(libs.springBootTestcontainers)
|
||||
api(libs.testcontainersPostgresql)
|
||||
api(libs.testcontainersJunitJupiter)
|
||||
api(libs.springBootStarterTest)
|
||||
api(libs.mockk)
|
||||
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
|
||||
api(libs.junitPlatformLauncher)
|
||||
}
|
||||
|
||||
@@ -8,30 +8,29 @@ import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
|
||||
@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)
|
||||
|
||||
init {
|
||||
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 isVideo = ext in VIDEO_EXTENSIONS
|
||||
val isImage = ext in IMAGE_EXTENSIONS
|
||||
if (!isVideo && !isImage) {
|
||||
throw BadRequestException(
|
||||
"Недопустимый тип файла: $originalName " +
|
||||
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
|
||||
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
|
||||
)
|
||||
}
|
||||
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
|
||||
if (bytes.size > limit) {
|
||||
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
|
||||
}
|
||||
requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS)
|
||||
requireSizeWithinLimit(originalName, bytes.size, isVideo)
|
||||
val publicName = "${UUID.randomUUID()}.$ext"
|
||||
val target = rootDir.resolve(publicName).normalize()
|
||||
if (!target.startsWith(rootDir)) {
|
||||
@@ -41,6 +40,31 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
|
||||
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>? {
|
||||
val clean = publicName.substringAfterLast('/')
|
||||
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)
|
||||
}
|
||||
|
||||
private fun mimeFor(ext: String): String = when (ext) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"webm" -> "video/webm"
|
||||
"ogg", "ogv" -> "video/ogg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
private fun mimeFor(ext: String): String =
|
||||
when (ext) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"webm" -> "video/webm"
|
||||
"ogg", "ogv" -> "video/ogg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
|
||||
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
|
||||
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
|
||||
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
|
||||
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
|
||||
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,17 +2,11 @@ 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.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class ArticleTreeNode(
|
||||
val id: String,
|
||||
@@ -40,7 +34,9 @@ data class ChapterTreeNode(
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
data class ContentTree(val chapters: List<ChapterTreeNode>)
|
||||
data class ContentTree(
|
||||
val chapters: List<ChapterTreeNode>,
|
||||
)
|
||||
|
||||
data class ContentArticleView(
|
||||
val id: String,
|
||||
@@ -68,27 +64,32 @@ data class ContentImportResult(
|
||||
* (и перенос разделов между главами), порядок статей внутри разделов
|
||||
* (и перенос статей между разделами). Применяется одним запросом.
|
||||
*/
|
||||
data class ReorderArticle(val id: String)
|
||||
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())
|
||||
data class ReorderArticle(
|
||||
val id: String,
|
||||
)
|
||||
|
||||
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 на странице главы). */
|
||||
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
|
||||
data class ReorderSections(
|
||||
val sections: List<ReorderSection> = emptyList(),
|
||||
)
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
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,
|
||||
data class ReorderArticles(
|
||||
val articles: List<ReorderArticle> = emptyList(),
|
||||
)
|
||||
|
||||
@Service
|
||||
@@ -96,18 +97,21 @@ class ContentService(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assets: AssetStorage,
|
||||
private val importer: ContentImporter,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
val sectionList =
|
||||
sections
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList =
|
||||
articles
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
ChapterTreeNode(
|
||||
@@ -116,18 +120,20 @@ class ContentService(
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||
},
|
||||
)
|
||||
},
|
||||
sections =
|
||||
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.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,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
|
||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
||||
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт")
|
||||
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
|
||||
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
|
||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
||||
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
||||
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
||||
requireVisible(a.published, includeHidden, "Статья скрыта")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
@@ -163,84 +169,44 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Применяет новое расположение глав, разделов и статей (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)
|
||||
private fun <T> requireFound(
|
||||
value: T?,
|
||||
message: String,
|
||||
): T = value ?: throw NotFoundException(message)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun requireVisible(
|
||||
published: Boolean,
|
||||
includeHidden: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
if (!includeHidden && !published) throw NotFoundException(message)
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы и статей внутри них (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()
|
||||
|
||||
@Transactional
|
||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
||||
fun setChapterPublished(
|
||||
chapterId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
ch.published = published
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setSectionPublished(sectionId: UUID, published: Boolean) {
|
||||
fun setSectionPublished(
|
||||
sectionId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
sec.published = published
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setArticlePublished(articleId: UUID, published: Boolean) {
|
||||
fun setArticlePublished(
|
||||
articleId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
a.published = published
|
||||
articles.save(a)
|
||||
@@ -267,400 +233,9 @@ class ContentService(
|
||||
}
|
||||
|
||||
@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 { 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",
|
||||
)
|
||||
}
|
||||
fun importZip(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
sync: Boolean = true,
|
||||
): ContentImportResult = importer.importZip(filename, bytes, sync)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -12,20 +12,25 @@ 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.content.ArticlePlacement
|
||||
import ru.ysu.lms.academic.content.ContentAuthoringService
|
||||
import ru.ysu.lms.academic.content.ContentImportResult
|
||||
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 java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/content")
|
||||
@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(
|
||||
val chapterId: String? = null,
|
||||
val sectionId: String? = null,
|
||||
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
val title: String,
|
||||
val bodyMd: String = "",
|
||||
)
|
||||
data class ArticleMovePayload(val sectionId: String)
|
||||
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)
|
||||
|
||||
data class ArticleMovePayload(
|
||||
val sectionId: String,
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -53,45 +75,42 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
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")
|
||||
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
fun chapterPublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
) {
|
||||
contentService.setChapterPublished(id, body.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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
/** Новое расположение материалов (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")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@@ -105,25 +124,35 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
fun list() = contentService.listAll()
|
||||
|
||||
@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}")
|
||||
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameChapter(id, body.title)
|
||||
fun renameChapter(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = authoring.renameChapter(id, body.title)
|
||||
|
||||
@DeleteMapping("/chapters/{id}")
|
||||
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
contentService.deleteChapter(id)
|
||||
fun deleteChapter(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
authoring.deleteChapter(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/sections")
|
||||
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
|
||||
contentService.createSection(chapterId, body.title, body.slug)
|
||||
fun createSection(
|
||||
@RequestParam chapterId: UUID,
|
||||
@RequestBody body: SectionCreatePayload,
|
||||
) = authoring.createSection(chapterId, body.title, body.slug)
|
||||
|
||||
@PutMapping("/sections/{id}")
|
||||
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameSection(id, body.title)
|
||||
fun renameSection(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = authoring.renameSection(id, body.title)
|
||||
|
||||
@DeleteMapping("/sections/{id}")
|
||||
fun deleteSection(
|
||||
@@ -131,35 +160,46 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
@RequestParam(required = false) moveTo: UUID?,
|
||||
): ResponseEntity<Void> {
|
||||
if (moveTo != null) {
|
||||
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
||||
authoring.moveArticlesAndDeleteSection(id, moveTo)
|
||||
} else {
|
||||
contentService.deleteSection(id)
|
||||
authoring.deleteSection(id)
|
||||
}
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/articles")
|
||||
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
|
||||
title = body.title,
|
||||
bodyMd = body.bodyMd,
|
||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||
newChapterTitle = body.newChapterTitle,
|
||||
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
||||
newSectionTitle = body.newSectionTitle,
|
||||
fun createArticle(
|
||||
@RequestBody body: ArticleCreatePayload,
|
||||
) = authoring.createArticlePlaced(
|
||||
ArticlePlacement(
|
||||
title = body.title,
|
||||
bodyMd = body.bodyMd,
|
||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||
newChapterTitle = body.newChapterTitle,
|
||||
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
||||
newSectionTitle = body.newSectionTitle,
|
||||
),
|
||||
)
|
||||
|
||||
@PutMapping("/articles/{id}")
|
||||
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
|
||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleUpdatePayload,
|
||||
) {
|
||||
authoring.updateArticle(id, body.title, body.bodyMd)
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/move")
|
||||
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
|
||||
contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
fun move(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleMovePayload,
|
||||
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
|
||||
@DeleteMapping("/articles/{id}")
|
||||
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
contentService.deleteArticle(id)
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
authoring.deleteArticle(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.content.AssetStorage
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import java.nio.file.Files
|
||||
|
||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||
@RestController
|
||||
@@ -18,7 +17,6 @@ class ContentPublicController(
|
||||
private val contentService: ContentService,
|
||||
private val assetStorage: AssetStorage,
|
||||
) {
|
||||
|
||||
@GetMapping("/tree")
|
||||
fun tree() = contentService.tree()
|
||||
|
||||
@@ -30,9 +28,15 @@ class ContentPublicController(
|
||||
): ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article)
|
||||
return ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
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}")
|
||||
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
|
||||
val (path, mime) = assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
fun asset(
|
||||
@PathVariable name: String,
|
||||
): ResponseEntity<FileSystemResource> {
|
||||
val (path, mime) =
|
||||
assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||
return ResponseEntity.ok()
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
.contentType(media)
|
||||
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
|
||||
.body(FileSystemResource(path))
|
||||
.cacheControl(
|
||||
org.springframework.http.CacheControl
|
||||
.maxAge(java.time.Duration.ofDays(7)),
|
||||
).body(FileSystemResource(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
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 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 java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/labs")
|
||||
class LabController(private val labService: LabService) {
|
||||
|
||||
class LabController(
|
||||
private val labService: LabService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<LabSummary> = labService.list()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): LabDetail = labService.detail(id)
|
||||
|
||||
@PostMapping("/import")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
|
||||
fun importLab(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
): LabImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun delete(@PathVariable id: UUID) {
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) {
|
||||
labService.delete(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
|
||||
labService.update(id, body)
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LabUpdateRequest,
|
||||
): LabDetail = labService.update(id, body)
|
||||
|
||||
@PostMapping("/{id}/variants")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
|
||||
labService.addVariant(id, body)
|
||||
fun addVariant(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: VariantSaveRequest,
|
||||
): LabDetail = labService.addVariant(id, body)
|
||||
|
||||
@PutMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
|
||||
|
||||
@DeleteMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
||||
labService.deleteVariant(id, variantId)
|
||||
fun deleteVariant(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable variantId: UUID,
|
||||
): LabDetail = labService.deleteVariant(id, variantId)
|
||||
|
||||
@PostMapping("/{id}/toggle-status")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun toggleStatus(@PathVariable id: UUID) {
|
||||
fun toggleStatus(
|
||||
@PathVariable id: UUID,
|
||||
) {
|
||||
labService.toggleStatus(id)
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@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")
|
||||
@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)
|
||||
return labService.detail(id)
|
||||
}
|
||||
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
|
||||
|
||||
@DeleteMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
|
||||
labService.deleteLevel(id, levelId)
|
||||
}
|
||||
fun deleteLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
): LabDetail = labService.deleteLevel(id, levelId)
|
||||
}
|
||||
|
||||
@@ -38,4 +38,4 @@ class ContentArticle(
|
||||
@Column(nullable = false) var bodyMd: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.OrderBy
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.time.Instant
|
||||
@@ -37,4 +35,4 @@ class Lab(
|
||||
var status: String = "draft",
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -28,4 +28,4 @@ class LabLevel(
|
||||
var position: Int = 0,
|
||||
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
|
||||
var pointsMax: java.math.BigDecimal? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,4 +18,4 @@ class Semester(
|
||||
var year: Int = 0,
|
||||
@Column(nullable = false)
|
||||
var active: Boolean = false,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,4 +29,4 @@ class Variant(
|
||||
var statementMd: String = "",
|
||||
@Column(nullable = false)
|
||||
var position: Int = 0,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -119,4 +119,4 @@ data class LabCreateRequest(
|
||||
data class LevelRenameRequest(
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
|
||||
*/
|
||||
@Component
|
||||
class LabImportParser {
|
||||
private val yaml = ObjectMapper(YAMLFactory())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
private val yaml =
|
||||
ObjectMapper(YAMLFactory())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.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")) {
|
||||
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
|
||||
}
|
||||
@@ -29,91 +33,117 @@ class LabImportParser {
|
||||
}
|
||||
|
||||
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>()
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
entries[entry.name] = zis.readAllBytes()
|
||||
}
|
||||
if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
|
||||
val levelMap = levels.associateBy { it.code }
|
||||
private fun readMeta(entries: Map<String, ByteArray>): LabMeta {
|
||||
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 firstSep = lines.indexOfFirst { it.trim() == "---" }
|
||||
if (firstSep != 0) {
|
||||
return VariantMeta(
|
||||
level = "basic",
|
||||
number = path.substringAfterLast("/").removeSuffix(".md"),
|
||||
title = "",
|
||||
statement = content.trim(),
|
||||
)
|
||||
}
|
||||
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
|
||||
if (secondSep < 0) return null
|
||||
val fmLines = lines.subList(1, secondSep)
|
||||
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()
|
||||
val frontMatter =
|
||||
readFrontMatter(lines)
|
||||
?: return VariantMeta(
|
||||
level = "basic",
|
||||
number = path.substringAfterLast("/").removeSuffix(".md"),
|
||||
title = "",
|
||||
statement = content.trim(),
|
||||
)
|
||||
val levelCode = normalizeLevel(frontMatter.values["level"] ?: "basic")
|
||||
val number = frontMatter.values["number"]?.takeIf { it.isNotBlank() }
|
||||
if (levels.none { it.code == levelCode } || number == null) return null
|
||||
val body = lines.drop(frontMatter.bodyStart).joinToString("\n").trim()
|
||||
return VariantMeta(
|
||||
level = levelCode,
|
||||
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null,
|
||||
title = fm["title"] ?: "",
|
||||
number = number,
|
||||
title = frontMatter.values["title"] ?: "",
|
||||
statement = body.ifBlank { "—" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun unquote(value: String): String =
|
||||
if (value.length >= 2 &&
|
||||
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\''))
|
||||
) {
|
||||
value.substring(1, value.length - 1)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
private data class FrontMatter(
|
||||
val values: Map<String, String>,
|
||||
val bodyStart: Int,
|
||||
)
|
||||
|
||||
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
|
||||
"main", "basic", "обычная", "основная" -> "basic"
|
||||
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
||||
else -> s.lowercase().trim()
|
||||
/** Читает YAML-front-matter (между двумя `---`), иначе возвращает null. */
|
||||
private fun readFrontMatter(lines: List<String>): FrontMatter? {
|
||||
if (lines.firstOrNull()?.trim() != "---") return null
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
|
||||
import ru.ysu.lms.academic.repo.LabRepository
|
||||
import ru.ysu.lms.academic.repo.SemesterRepository
|
||||
import ru.ysu.lms.academic.repo.VariantRepository
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -28,12 +28,15 @@ class LabService(
|
||||
private val semesters: SemesterRepository,
|
||||
private val parser: LabImportParser,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
|
||||
fun importFromFile(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): LabImportResult {
|
||||
val bundle = parser.parse(filename, bytes)
|
||||
val semester = semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
val semester =
|
||||
semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
|
||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||
throw ConflictException(
|
||||
@@ -41,47 +44,44 @@ class LabService(
|
||||
)
|
||||
}
|
||||
|
||||
val lab = labs.save(Lab(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
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 lab =
|
||||
labs.save(
|
||||
Lab(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
||||
defenseAt = parseInstant(bundle.meta.defenseAt),
|
||||
),
|
||||
)
|
||||
}
|
||||
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 levelMetas =
|
||||
bundle.meta.levels.ifEmpty {
|
||||
listOf(
|
||||
LabLevelMeta("basic", "Основная группа", position = 1),
|
||||
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
||||
)
|
||||
}
|
||||
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 }
|
||||
|
||||
bundle.variants.forEach { vmeta ->
|
||||
val level = levelByCode[vmeta.level]
|
||||
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
|
||||
variantsRepo.save(Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = vmeta.number,
|
||||
title = vmeta.title,
|
||||
statementMd = vmeta.statement,
|
||||
))
|
||||
}
|
||||
saveVariants(lab, levelByCode, bundle.variants)
|
||||
|
||||
return LabImportResult(
|
||||
labId = lab.id.toString(),
|
||||
@@ -122,25 +122,29 @@ class LabService(
|
||||
deadlineAt = lab.deadlineAt?.toString(),
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
levels = levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
.filter { it.labLevel?.id == level.id }
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
title = level.title,
|
||||
variants = vs.map { v ->
|
||||
VariantDetail(
|
||||
id = v.id.toString(),
|
||||
number = v.number,
|
||||
title = v.title,
|
||||
statementMd = v.statementMd,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
levels =
|
||||
levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs =
|
||||
variantsRepo
|
||||
.findByLabIdOrderByNumber(lab.id)
|
||||
.filter { it.labLevel?.id == level.id }
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
title = level.title,
|
||||
variants =
|
||||
vs.map { v ->
|
||||
VariantDetail(
|
||||
id = v.id.toString(),
|
||||
number = v.number,
|
||||
title = v.title,
|
||||
statementMd = v.statementMd,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +157,10 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
|
||||
fun update(
|
||||
labId: UUID,
|
||||
request: LabUpdateRequest,
|
||||
): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
lab.title = request.title.trim()
|
||||
@@ -167,70 +174,77 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
|
||||
val variant = variantsRepo.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
fun updateVariant(
|
||||
labId: UUID,
|
||||
variantId: UUID,
|
||||
request: VariantSaveRequest,
|
||||
): LabDetail {
|
||||
val variant =
|
||||
variantsRepo
|
||||
.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
variant.number = request.number.trim()
|
||||
variant.title = request.title.orEmpty().trim()
|
||||
variant.statementMd = request.statementMd.orEmpty()
|
||||
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
|
||||
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variant.labLevel =
|
||||
labLevels
|
||||
.findById(UUID.fromString(levelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
}
|
||||
variantsRepo.save(variant)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
|
||||
fun addVariant(
|
||||
labId: UUID,
|
||||
request: VariantSaveRequest,
|
||||
): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
val level = labLevels.findById(UUID.fromString(request.labLevelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variantsRepo.save(Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = request.number.trim(),
|
||||
title = request.title.orEmpty().trim(),
|
||||
statementMd = request.statementMd.orEmpty(),
|
||||
))
|
||||
val level =
|
||||
labLevels
|
||||
.findById(UUID.fromString(request.labLevelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variantsRepo.save(
|
||||
Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = request.number.trim(),
|
||||
title = request.title.orEmpty().trim(),
|
||||
statementMd = request.statementMd.orEmpty(),
|
||||
),
|
||||
)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
|
||||
val variant = variantsRepo.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
fun deleteVariant(
|
||||
labId: UUID,
|
||||
variantId: UUID,
|
||||
): LabDetail {
|
||||
val variant =
|
||||
variantsRepo
|
||||
.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||
variantsRepo.delete(variant)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: LabCreateRequest): LabDetail {
|
||||
private fun validateCreate(request: LabCreateRequest) {
|
||||
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
|
||||
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",
|
||||
),
|
||||
)
|
||||
val levelMetas = request.levels.ifEmpty {
|
||||
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLevels(
|
||||
lab: Lab,
|
||||
levels: List<LevelSaveRequest>,
|
||||
) {
|
||||
val levelMetas = levels.ifEmpty { listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) }
|
||||
levelMetas.forEachIndexed { index, meta ->
|
||||
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
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)
|
||||
}
|
||||
|
||||
@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("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
level.title = request.title.ifBlank { level.title }
|
||||
@@ -257,7 +319,10 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
|
||||
fun deleteLevel(
|
||||
labId: UUID,
|
||||
levelId: UUID,
|
||||
): LabDetail {
|
||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
|
||||
@@ -267,8 +332,11 @@ class LabService(
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
||||
@Transactional
|
||||
fun addLevel(
|
||||
labId: UUID,
|
||||
request: LevelSaveRequest,
|
||||
) {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
||||
@@ -290,15 +358,24 @@ class LabService(
|
||||
labs.save(lab)
|
||||
}
|
||||
|
||||
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
||||
private fun findOrCreateSemester(
|
||||
title: String,
|
||||
year: Int,
|
||||
): Semester {
|
||||
semesters.findFirstByActiveTrue()?.let { return it }
|
||||
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))
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int =
|
||||
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
@@ -307,4 +384,4 @@ class LabService(
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,40 @@ import java.util.UUID
|
||||
|
||||
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
||||
|
||||
fun findBySlug(slug: String): ContentChapter?
|
||||
|
||||
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
||||
}
|
||||
|
||||
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): 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> {
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@ import java.util.UUID
|
||||
|
||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
|
||||
}
|
||||
|
||||
fun existsByNumberAndSemesterId(
|
||||
number: Int,
|
||||
semesterId: UUID,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ import java.util.UUID
|
||||
|
||||
interface SemesterRepository : JpaRepository<Semester, UUID> {
|
||||
fun findFirstByActiveTrue(): Semester?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
|
||||
|
||||
interface VariantRepository : JpaRepository<Variant, UUID> {
|
||||
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder =
|
||||
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@Bean
|
||||
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||
val role = jwt.getClaimAsString("role")
|
||||
if (role.isNullOrBlank()) emptyList()
|
||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
if (role.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
@@ -43,17 +43,17 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/api/v1/content/tree",
|
||||
"/api/v1/content/assets/**",
|
||||
"/api/v1/content/{chapter}/{section}/{article}",
|
||||
).permitAll()
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/api/v1/content/tree",
|
||||
"/api/v1/content/assets/**",
|
||||
"/api/v1/content/{chapter}/{section}/{article}",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
}.oauth2ResourceServer { oauth ->
|
||||
oauth.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
}
|
||||
@@ -64,4 +64,4 @@ class SecurityConfig {
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("варианты")
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,30 @@ import reactor.core.publisher.Mono
|
||||
* identity to the downstream services as `X-Account-*` headers.
|
||||
*/
|
||||
@Component
|
||||
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
|
||||
|
||||
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
||||
class JwtAuthenticationFilter(
|
||||
private val jwtDecoder: JwtDecoder,
|
||||
) : GlobalFilter,
|
||||
Ordered {
|
||||
override fun filter(
|
||||
exchange: ServerWebExchange,
|
||||
chain: GatewayFilterChain,
|
||||
): Mono<Void> {
|
||||
val path = exchange.request.path.value()
|
||||
if (isPublic(path)) {
|
||||
return chain.filter(exchange)
|
||||
}
|
||||
val token = bearerToken(exchange)
|
||||
?: return unauthorized(exchange, "Требуется вход")
|
||||
val token =
|
||||
bearerToken(exchange)
|
||||
?: return unauthorized(exchange, "Требуется вход")
|
||||
return try {
|
||||
val jwt = jwtDecoder.decode(token)
|
||||
val request = exchange.request.mutate()
|
||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
||||
.build()
|
||||
val request =
|
||||
exchange.request
|
||||
.mutate()
|
||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
||||
.build()
|
||||
chain.filter(exchange.mutate().request(request).build())
|
||||
} catch (_: JwtException) {
|
||||
unauthorized(exchange, "Недействительный или истёкший токен")
|
||||
@@ -56,7 +64,10 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
|
||||
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
|
||||
response.statusCode = HttpStatus.UNAUTHORIZED
|
||||
response.headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
@@ -18,23 +18,28 @@ class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val rsaKey: RSAKey,
|
||||
) {
|
||||
|
||||
@GetMapping("/health")
|
||||
fun health(): Map<String, Any> = mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
fun health(): Map<String, Any> =
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
|
||||
@PostMapping("/setup")
|
||||
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
|
||||
fun setup(
|
||||
@Valid @RequestBody body: SetupRequest,
|
||||
): AuthResponse = authService.setup(body)
|
||||
|
||||
@PostMapping("/login")
|
||||
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
|
||||
fun login(
|
||||
@Valid @RequestBody body: LoginRequest,
|
||||
): AuthResponse = authService.login(body)
|
||||
|
||||
@GetMapping("/me")
|
||||
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
|
||||
authService.me(UUID.fromString(jwt.subject))
|
||||
fun me(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): MeResponse = authService.me(UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/change-password")
|
||||
fun changePassword(
|
||||
|
||||
@@ -28,7 +28,6 @@ class AuthService(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
) {
|
||||
|
||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||
|
||||
@Transactional
|
||||
@@ -41,20 +40,22 @@ class AuthService(
|
||||
throw BadRequestException("Логин: минимум 3 символа")
|
||||
}
|
||||
validatePassword(request.password, request.password2)
|
||||
val account = Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
val account =
|
||||
Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
accounts.save(account)
|
||||
return toResponse(account, account.login)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun login(request: LoginRequest): AuthResponse {
|
||||
val account = accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
val account =
|
||||
accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
@@ -63,8 +64,10 @@ class AuthService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun me(accountId: UUID): MeResponse {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
return MeResponse(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
@@ -75,9 +78,14 @@ class AuthService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
fun changePassword(
|
||||
accountId: UUID,
|
||||
request: ChangePasswordRequest,
|
||||
) {
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
||||
throw UnauthorizedException("Текущий пароль неверный")
|
||||
}
|
||||
@@ -89,7 +97,10 @@ class AuthService(
|
||||
accounts.save(account)
|
||||
}
|
||||
|
||||
private fun validatePassword(password: String, confirmation: String) {
|
||||
private fun validatePassword(
|
||||
password: String,
|
||||
confirmation: String,
|
||||
) {
|
||||
if (password.length < 8) {
|
||||
throw BadRequestException("Пароль: минимум 8 символов")
|
||||
}
|
||||
@@ -100,33 +111,43 @@ class AuthService(
|
||||
|
||||
private fun displayName(account: Account): String =
|
||||
if (account.role == LmsRole.STUDENT) {
|
||||
students.findById(account.id)
|
||||
students
|
||||
.findById(account.id)
|
||||
.map { it.fullName.ifBlank { account.login } }
|
||||
.orElse(account.login)
|
||||
} else {
|
||||
account.login
|
||||
}
|
||||
|
||||
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = name,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
private fun toResponse(
|
||||
account: Account,
|
||||
name: String,
|
||||
): AuthResponse =
|
||||
AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
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 claims = JwtClaimsSet.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val claims =
|
||||
JwtClaimsSet
|
||||
.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
|
||||
@@ -16,26 +16,19 @@ import java.util.UUID
|
||||
class Account(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
var login: String = "",
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
var passwordHash: String = "",
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var role: LmsRole = LmsRole.STUDENT,
|
||||
|
||||
@Column(name = "must_change_password", nullable = false)
|
||||
var mustChangePassword: Boolean = true,
|
||||
|
||||
@Column(name = "temp_password", length = 64)
|
||||
var tempPassword: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -16,15 +16,12 @@ import java.util.UUID
|
||||
class Enrollment(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "group_id", nullable = false)
|
||||
var group: GroupEntity? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "student_id", nullable = false)
|
||||
var student: Student? = null,
|
||||
|
||||
@Column(name = "enrolled_at", nullable = false)
|
||||
var enrolledAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -13,13 +13,10 @@ import java.util.UUID
|
||||
class GroupEntity(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
var name: String = "",
|
||||
|
||||
@Column(name = "task_duration_min", nullable = false)
|
||||
var taskDurationMin: Int = 30,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ class Setting(
|
||||
@Id
|
||||
@Column(name = "key", length = 100)
|
||||
var key: String = "",
|
||||
|
||||
@Column(name = "value", nullable = false)
|
||||
var value: String = "",
|
||||
)
|
||||
|
||||
@@ -16,32 +16,26 @@ class Student(
|
||||
@Id
|
||||
@Column(name = "account_id")
|
||||
var accountId: UUID? = null,
|
||||
|
||||
@Column(name = "student_no", nullable = false, unique = true, length = 32)
|
||||
var studentNo: String = "",
|
||||
|
||||
@Column(name = "last_name", nullable = false, length = 100)
|
||||
var lastName: String = "",
|
||||
|
||||
@Column(name = "first_name", nullable = false, length = 100)
|
||||
var firstName: String = "",
|
||||
|
||||
@Column(name = "middle_name", nullable = false, length = 100)
|
||||
var middleName: String = "",
|
||||
|
||||
@Column(name = "basis", nullable = false, length = 100)
|
||||
var basis: String = "",
|
||||
|
||||
@Column(name = "note", nullable = false)
|
||||
var note: String = "",
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "account_id")
|
||||
var account: Account? = null,
|
||||
) {
|
||||
val fullName: String
|
||||
get() = listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
get() =
|
||||
listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import java.util.UUID
|
||||
|
||||
interface AccountRepository : JpaRepository<Account, UUID> {
|
||||
fun findByLogin(login: String): Account?
|
||||
|
||||
fun existsByLogin(login: String): Boolean
|
||||
|
||||
fun existsByRole(role: LmsRole): Boolean
|
||||
|
||||
fun countByRole(role: LmsRole): Long
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
|
||||
import java.util.UUID
|
||||
|
||||
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||
|
||||
@Query(
|
||||
"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 findFirstByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findByStudentAccountId(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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.util.UUID
|
||||
|
||||
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
fun findByName(name: String): GroupEntity?
|
||||
|
||||
fun existsByName(name: String): Boolean
|
||||
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ import java.util.UUID
|
||||
|
||||
interface StudentRepository : JpaRepository<Student, UUID> {
|
||||
fun findByStudentNo(studentNo: String): Student?
|
||||
|
||||
fun existsByStudentNo(studentNo: String): Boolean
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object CredentialsExporter {
|
||||
|
||||
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
|
||||
|
||||
fun csv(rows: List<StudentRow>): ByteArray {
|
||||
val builder = StringBuilder()
|
||||
builder.append(HEADERS.joinToString(";")).append("\r\n")
|
||||
rows.forEach { row ->
|
||||
builder.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
builder
|
||||
.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
}
|
||||
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
@@ -41,8 +41,7 @@ object CredentialsExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(row: StudentRow): String =
|
||||
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
|
||||
private fun escapeCsv(value: String): String =
|
||||
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/groups")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class GroupController(private val groupService: GroupService) {
|
||||
|
||||
class GroupController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
|
||||
@PostMapping
|
||||
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
|
||||
fun create(
|
||||
@Valid @RequestBody body: CreateGroupRequest,
|
||||
): GroupSummary = groupService.create(body)
|
||||
|
||||
@PostMapping("/{id}/students")
|
||||
fun createStudent(
|
||||
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@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")
|
||||
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.enroll(id, studentId)
|
||||
fun enroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.enroll(id, studentId)
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.unenroll(id, studentId)
|
||||
fun unenroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.unenroll(id, studentId)
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
|
||||
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
fun myGroup(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): GroupDetail = groupService.detail(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
|
||||
): ResponseEntity<ByteArray> {
|
||||
val export = groupService.credentials(id, format)
|
||||
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")
|
||||
.contentType(MediaType.parseMediaType(export.contentType))
|
||||
.body(export.bytes)
|
||||
|
||||
@@ -27,12 +27,14 @@ class GroupService(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
|
||||
fun createStudent(
|
||||
groupId: UUID,
|
||||
request: CreateStudentRequest,
|
||||
): Credential {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val studentNo = request.studentNo.trim()
|
||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||
@@ -40,27 +42,29 @@ class GroupService(
|
||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||
}
|
||||
val password = Passwords.generate()
|
||||
val account = accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||
val student = students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
lastName = request.lastName.trim(),
|
||||
firstName = request.firstName.trim(),
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
val student =
|
||||
students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
lastName = request.lastName.trim(),
|
||||
firstName = request.firstName.trim(),
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
@@ -85,13 +89,15 @@ class GroupService(
|
||||
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
|
||||
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
|
||||
for (e in enrollments.findAll()) {
|
||||
val sid = e.student?.accountId ?: continue
|
||||
val gid = e.group?.id ?: continue
|
||||
val name = e.group?.name ?: continue
|
||||
currentByStudent[sid] = gid to name
|
||||
}
|
||||
return students.findAll()
|
||||
enrollments
|
||||
.findAll()
|
||||
.mapNotNull { enrollment ->
|
||||
val sid = enrollment.student?.accountId ?: return@mapNotNull null
|
||||
val group = enrollment.group ?: return@mapNotNull null
|
||||
sid to (group.id to group.name)
|
||||
}.forEach { (sid, group) -> currentByStudent[sid] = group }
|
||||
return students
|
||||
.findAll()
|
||||
.filter { it.accountId !in memberIds }
|
||||
.map { s ->
|
||||
val current = s.accountId?.let { currentByStudent[it] }
|
||||
@@ -102,16 +108,18 @@ class GroupService(
|
||||
currentGroupId = current?.first?.toString(),
|
||||
currentGroupName = current?.second,
|
||||
)
|
||||
}
|
||||
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
||||
@Transactional
|
||||
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
fun enroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val group = groups.findById(groupId).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.group?.id == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
@@ -127,15 +135,23 @@ class GroupService(
|
||||
|
||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||
@Transactional
|
||||
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
fun unenroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val enrollment =
|
||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@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 groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
||||
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
||||
@@ -143,63 +159,80 @@ class GroupService(
|
||||
val updated = mutableListOf<String>()
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val existing = students.findByStudentNo(parsedStudent.studentNo)
|
||||
val student = if (existing != null) {
|
||||
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)
|
||||
}
|
||||
val student = upsertStudent(parsedStudent, added, updated)
|
||||
enrollIntoGroup(group, student)
|
||||
}
|
||||
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)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
|
||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||
return GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
@@ -211,25 +244,30 @@ class GroupService(
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
val rows =
|
||||
enrollments
|
||||
.findAllByGroupId(groupId)
|
||||
.map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||
val student = students.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val student =
|
||||
students
|
||||
.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
||||
val password = Passwords.generate()
|
||||
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
|
||||
@@ -240,28 +278,34 @@ class GroupService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun credentials(groupId: UUID, format: String): ExportFile {
|
||||
fun credentials(
|
||||
groupId: UUID,
|
||||
format: String,
|
||||
): ExportFile {
|
||||
val detail = detail(groupId)
|
||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||
return when (format.lowercase()) {
|
||||
"csv" -> ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" -> ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
"csv" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
else -> throw BadRequestException("Формат должен быть xlsx или csv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroup_Id(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
private fun summary(group: GroupEntity): GroupSummary =
|
||||
GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroupId(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/students")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class StudentController(private val groupService: GroupService) {
|
||||
|
||||
class StudentController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@PostMapping("/{id}/reset-password")
|
||||
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
|
||||
groupService.resetPassword(id)
|
||||
fun resetPassword(
|
||||
@PathVariable id: UUID,
|
||||
): ResetPasswordResponse = groupService.resetPassword(id)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ data class ParsedGroup(
|
||||
|
||||
@Component
|
||||
class StudentImportParser {
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
|
||||
fun parse(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ParsedGroup {
|
||||
val lower = filename.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||
@@ -65,7 +67,10 @@ class StudentImportParser {
|
||||
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 current = StringBuilder()
|
||||
var inQuotes = false
|
||||
@@ -83,51 +88,73 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
|
||||
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
||||
if (headerIndex < 0) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
||||
}
|
||||
val columns = mapColumns(rows[headerIndex])
|
||||
if (columns["studentNo"] == null) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
||||
}
|
||||
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
}
|
||||
ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
)
|
||||
}
|
||||
private fun parseRows(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): ParsedGroup {
|
||||
val headerIndex = requireHeader(rows)
|
||||
val columns = requireColumns(rows[headerIndex])
|
||||
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
|
||||
if (students.isEmpty()) {
|
||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||
}
|
||||
return ParsedGroup(groupNameFrom(filename, rows), students)
|
||||
}
|
||||
|
||||
private fun requireHeader(rows: List<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 {
|
||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
|
||||
return normalized.startsWith("студен") ||
|
||||
normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") ||
|
||||
normalized.startsWith("зачет")
|
||||
}
|
||||
|
||||
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
header.forEachIndexed { index, raw ->
|
||||
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) }) {
|
||||
result[key] = index
|
||||
}
|
||||
@@ -142,10 +169,12 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeNumber(value: String): String =
|
||||
if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
private fun normalizeNumber(value: String): String = 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]*")
|
||||
for (row in rows.take(12)) {
|
||||
for (cell in row) {
|
||||
|
||||
@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
|
||||
|
||||
@Configuration
|
||||
@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
|
||||
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
|
||||
.privateKey(keyStore.privateKey)
|
||||
.keyID("lms")
|
||||
.build()
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
|
||||
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
|
||||
|
||||
@@ -15,8 +15,9 @@ import java.util.Base64
|
||||
* in the `settings` table, so no private key ever lives in the repository.
|
||||
*/
|
||||
@Service
|
||||
class JwtKeyStore(private val settings: SettingRepository) {
|
||||
|
||||
class JwtKeyStore(
|
||||
private val settings: SettingRepository,
|
||||
) {
|
||||
lateinit var privateKey: RSAPrivateKey
|
||||
|
||||
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)
|
||||
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
@@ -26,19 +25,19 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { resource ->
|
||||
}.oauth2ResourceServer { resource ->
|
||||
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
||||
}
|
||||
return http.build()
|
||||
|
||||
@@ -8,20 +8,20 @@ import org.springframework.test.context.DynamicPropertySource
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class IdentitySmokeTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
val pg = PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
|
||||
|
||||
@Test
|
||||
fun `setup creates admin and login works`() {
|
||||
val token = authService.setup(
|
||||
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
|
||||
)
|
||||
val token =
|
||||
authService.setup(
|
||||
ru.ysu.lms.identity.auth
|
||||
.SetupRequest("admin", "test12345", "test12345"),
|
||||
)
|
||||
assert(token.token.isNotBlank()) { "token should not be blank" }
|
||||
assert(token.role == "ADMIN")
|
||||
|
||||
val login = authService.login(
|
||||
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
|
||||
)
|
||||
val login =
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "test12345"),
|
||||
)
|
||||
assert(login.token.isNotBlank())
|
||||
|
||||
val ex = try {
|
||||
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
|
||||
null
|
||||
} catch (e: UnauthorizedException) { e }
|
||||
val ex =
|
||||
try {
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "bad"),
|
||||
)
|
||||
null
|
||||
} catch (e: UnauthorizedException) {
|
||||
e
|
||||
}
|
||||
assert(ex != null) { "bad password should throw" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ dependencies {
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,28 @@ class AcademicClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getLab(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLab(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
|
||||
fun getLabSummary(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLabSummary(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
}
|
||||
|
||||
@@ -31,16 +43,25 @@ class IdentityClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getGroup(groupId: String, token: String): GroupInfo =
|
||||
client.get().uri("/api/v1/groups/{id}", groupId)
|
||||
fun getGroup(
|
||||
groupId: String,
|
||||
token: String,
|
||||
): GroupInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/groups/{id}", groupId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(GroupInfo::class.java)
|
||||
.retrieve()
|
||||
.body(GroupInfo::class.java)
|
||||
?: throw NotFoundException("Группа не найдена")
|
||||
|
||||
fun listGroups(token: String): 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")
|
||||
.retrieve().body(type) ?: emptyList()
|
||||
.retrieve()
|
||||
.body(type) ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
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
|
||||
|
||||
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(
|
||||
val id: String,
|
||||
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/defenses")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class DefenseAdminController(private val service: DefenseService) {
|
||||
|
||||
class DefenseAdminController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
@PostMapping("/assign")
|
||||
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto =
|
||||
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
fun assign(
|
||||
@RequestBody body: DefenseAssignPayload,
|
||||
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
|
||||
@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))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> =
|
||||
fun list(
|
||||
@RequestParam labId: UUID,
|
||||
): List<DefenseAdminDto> =
|
||||
service.listForLab(labId).map {
|
||||
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd,
|
||||
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft)
|
||||
DefenseAdminDto(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
|
||||
@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")
|
||||
fun reset(@PathVariable id: UUID) = service.reset(id)
|
||||
fun reset(
|
||||
@PathVariable id: UUID,
|
||||
) = service.reset(id)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me/defenses")
|
||||
class DefenseMyController(private val service: DefenseService) {
|
||||
|
||||
data class StartByCodePayload(val code: String)
|
||||
class DefenseMyController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
data class StartByCodePayload(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> =
|
||||
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
fun my(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
|
||||
@GetMapping("/{labId}")
|
||||
fun one(
|
||||
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
|
||||
|
||||
private fun hideCode(d: DefenseDto): DefenseDto =
|
||||
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
|
||||
@PostMapping("/start")
|
||||
fun start(
|
||||
|
||||
@@ -31,7 +31,6 @@ data class DefenseDto(
|
||||
class DefenseService(
|
||||
private val sessions: DefenseSessionRepository,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val DURATION: Duration = Duration.ofMinutes(30)
|
||||
private val random = SecureRandom()
|
||||
@@ -55,7 +54,11 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@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 s = existing ?: DefenseSession(studentId = studentId, labId = labId)
|
||||
// смена задания сбрасывает активную попытку защиты
|
||||
@@ -106,19 +109,44 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun startByCode(studentId: UUID, code: String): DefenseDto {
|
||||
fun startByCode(
|
||||
studentId: UUID,
|
||||
code: String,
|
||||
): DefenseDto {
|
||||
syncTimeouts()
|
||||
val s = sessions.findByCode(code.trim())
|
||||
?: throw BadRequestException("Код не найден")
|
||||
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
|
||||
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
|
||||
val s = requireWaitingSession(studentId, code)
|
||||
s.status = DefenseStatus.started.name
|
||||
s.startedAt = Instant.now()
|
||||
return toDto(sessions.save(s))
|
||||
}
|
||||
|
||||
private fun requireWaitingSession(
|
||||
studentId: UUID,
|
||||
code: String,
|
||||
): DefenseSession {
|
||||
val 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
|
||||
fun finish(studentId: UUID, sessionId: UUID): DefenseDto {
|
||||
fun finish(
|
||||
studentId: UUID,
|
||||
sessionId: UUID,
|
||||
): DefenseDto {
|
||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||
@@ -135,13 +163,19 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun mySession(studentId: UUID, labId: UUID): DefenseDto? {
|
||||
fun mySession(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): DefenseDto? {
|
||||
syncTimeouts()
|
||||
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetByStudentLab(studentId: UUID, labId: UUID) {
|
||||
fun resetByStudentLab(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
) {
|
||||
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
|
||||
s.status = DefenseStatus.waiting.name
|
||||
s.code = null
|
||||
@@ -152,11 +186,18 @@ class DefenseService(
|
||||
}
|
||||
|
||||
private fun toDto(s: DefenseSession): DefenseDto {
|
||||
val left = if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0)
|
||||
remain
|
||||
} else null
|
||||
val left =
|
||||
if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain =
|
||||
java.time.Duration
|
||||
.between(Instant.now(), started.plus(DURATION))
|
||||
.seconds
|
||||
.coerceAtLeast(0)
|
||||
remain
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return DefenseDto(
|
||||
id = s.id.toString(),
|
||||
studentId = s.studentId.toString(),
|
||||
|
||||
@@ -136,8 +136,15 @@ data class GradeSetRequest(
|
||||
val comments: List<CommentInput> = emptyList(),
|
||||
)
|
||||
|
||||
data class CommentInput(val text: String, val tone: String = "neutral")
|
||||
data class CommentOut(val text: String, val tone: String)
|
||||
data class CommentInput(
|
||||
val text: String,
|
||||
val tone: String = "neutral",
|
||||
)
|
||||
|
||||
data class CommentOut(
|
||||
val text: String,
|
||||
val tone: String,
|
||||
)
|
||||
|
||||
data class GradeResponse(
|
||||
val studentId: String,
|
||||
@@ -153,4 +160,4 @@ data class SubmissionDto(
|
||||
val labId: String,
|
||||
val createdAt: String,
|
||||
val files: List<String>,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.domain.GradePublication
|
||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GradePublicationService(private val repo: GradePublicationRepository) {
|
||||
|
||||
class GradePublicationService(
|
||||
private val repo: GradePublicationRepository,
|
||||
) {
|
||||
@Transactional
|
||||
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
|
||||
fun setPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
||||
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
||||
if (!published && existing != null) repo.delete(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun isPublished(groupId: UUID, labId: UUID): Boolean =
|
||||
repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
fun isPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> =
|
||||
repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
|
||||
repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
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.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class ProgressController(
|
||||
private val service: ProgressService,
|
||||
private val grades: GradeService,
|
||||
private val publications: GradePublicationService,
|
||||
private val submissions: SubmissionService,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@GetMapping("/me/assignments")
|
||||
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
||||
fun meAssignments(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<MeAssignmentResponse> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
return service.getStudentAssignments(sid).mapNotNull { va ->
|
||||
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
|
||||
@@ -31,9 +30,7 @@ class ProgressController(
|
||||
}
|
||||
|
||||
@GetMapping("/me/labs")
|
||||
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
|
||||
return listOf()
|
||||
}
|
||||
fun meLabs(): List<Map<String, Any>> = listOf()
|
||||
|
||||
@PostMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -44,7 +41,9 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
|
||||
fun getLevels(
|
||||
@RequestParam labId: UUID,
|
||||
): List<LevelAssignmentResponse> =
|
||||
service.getLevelsForLab(labId).map { sll ->
|
||||
LevelAssignmentResponse(
|
||||
studentId = sll.studentId.toString(),
|
||||
@@ -77,84 +76,50 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/grades")
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
||||
fun listGrades(): List<GradeResponse> = grades.listGrades()
|
||||
|
||||
@PostMapping("/grades/publish")
|
||||
@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)
|
||||
}
|
||||
|
||||
@GetMapping("/grades/published")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
|
||||
publications.isPublished(groupId, labId)
|
||||
fun gradesPublished(
|
||||
@RequestParam("labId") labId: java.util.UUID,
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): Boolean = publications.isPublished(groupId, labId)
|
||||
|
||||
@GetMapping("/grades/publications")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
|
||||
publications.publishedLabIdsForGroup(groupId)
|
||||
fun groupPublications(
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): List<String> = publications.publishedLabIdsForGroup(groupId)
|
||||
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||
): 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()
|
||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
||||
}
|
||||
|
||||
@GetMapping("/comment-templates")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
|
||||
|
||||
@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)
|
||||
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)
|
||||
}
|
||||
fun suggestComments(
|
||||
@RequestParam q: String,
|
||||
): List<CommentOut> = grades.suggestComments(q)
|
||||
}
|
||||
|
||||
@@ -3,34 +3,25 @@ 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.domain.StudentLabLevel
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
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.VariantAssignmentRepository
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ProgressService(
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val grades: LabGradeRepository,
|
||||
private val templates: CommentTemplateRepository,
|
||||
private val gradeComments: GradeCommentRepository,
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
|
||||
fun setLevel(
|
||||
request: LevelAssignmentRequest,
|
||||
token: String,
|
||||
): LevelAssignmentResponse {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = StudentLabLevelKey(sid, lid)
|
||||
@@ -46,20 +37,27 @@ class ProgressService(
|
||||
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
||||
if (level != null && level.variants.isNotEmpty()) {
|
||||
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val currentInLevel = assignment?.variantId?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
val currentInLevel =
|
||||
assignment
|
||||
?.variantId
|
||||
?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
if (!currentInLevel) {
|
||||
val used = assignments.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val used =
|
||||
assignments
|
||||
.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val pool = level.variants.map { it.id }
|
||||
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
val chosen =
|
||||
pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target =
|
||||
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
assignments.save(target)
|
||||
}
|
||||
}
|
||||
@@ -72,98 +70,37 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
|
||||
levels.findByStudentIdAndLabId(studentId, labId)
|
||||
fun getLevel(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
|
||||
levels.findByLabId(labId)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun distribute(request: DistributeRequest, token: String): DistributeResult {
|
||||
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||
|
||||
fun distribute(
|
||||
request: DistributeRequest,
|
||||
token: String,
|
||||
): DistributeResult {
|
||||
val labIds = resolveLabIds(request)
|
||||
val force = request.force == true
|
||||
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))
|
||||
}
|
||||
}
|
||||
val allStudents = studentIds.distinct()
|
||||
|
||||
val allStudents = collectStudentIds(request, token)
|
||||
var assignedTotal = 0
|
||||
var totalStudents = 0
|
||||
var lastLabId: String? = null
|
||||
var lastLabTitle: String? = null
|
||||
|
||||
for (labIdStr in labIds) {
|
||||
val labId = UUID.fromString(labIdStr)
|
||||
val lab = academic.getLab(labIdStr, token)
|
||||
|
||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
||||
// закреплённые вручную варианты остаются как есть.
|
||||
if (force) {
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
if (force) clearAutoAssignments(labId, allStudents)
|
||||
val kept = keepExisting(labId, allStudents)
|
||||
assignedTotal += kept.size
|
||||
assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
|
||||
totalStudents += allStudents.size
|
||||
lastLabId = labIdStr
|
||||
lastLabTitle = lab.title
|
||||
}
|
||||
|
||||
return DistributeResult(
|
||||
labs = labIds.size,
|
||||
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)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
||||
assignments.findByLabId(labId)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun assign(request: AssignRequest): VariantAssignment {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val existing = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
val entity =
|
||||
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
return assignments.save(entity)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
|
||||
fun assignWithInfo(
|
||||
request: AssignRequest,
|
||||
token: String,
|
||||
): VariantAssignmentResponse {
|
||||
assign(request)
|
||||
val lab = academic.getLab(request.labId, token)
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
|
||||
@@ -204,7 +238,10 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@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 variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
|
||||
return assignments.findByLabId(labId).map { va ->
|
||||
@@ -220,76 +257,22 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
||||
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"
|
||||
}
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
|
||||
|
||||
@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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
||||
fun getStudentAssignment(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
token: String,
|
||||
): MeAssignmentResponse? {
|
||||
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
||||
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 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() } }
|
||||
@@ -306,4 +289,4 @@ class ProgressService(
|
||||
pointsMax = lab.pointsMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
|
||||
@SpringBootApplication
|
||||
class ProgressServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ProgressServiceApplication>(*args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -18,7 +18,6 @@ class SubmissionService(
|
||||
private val storage: SubmissionStorage,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun submit(
|
||||
studentId: UUID,
|
||||
@@ -26,30 +25,71 @@ class SubmissionService(
|
||||
files: List<MultipartFile>,
|
||||
token: String,
|
||||
): SubmissionDto {
|
||||
if (files.isEmpty() || files.all { it.isEmpty }) {
|
||||
throw BadRequestException("Приложите файлы или архив")
|
||||
}
|
||||
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 payload = validatedPayload(files)
|
||||
ensureDeadline(requireLab(labId, token))
|
||||
val seq = nextSeq(studentId, labId)
|
||||
val stored = storage.store(studentId, labId, seq, payload)
|
||||
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
|
||||
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)
|
||||
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
|
||||
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
|
||||
fun mySubmissions(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<SubmissionDto> =
|
||||
submissions
|
||||
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
||||
.map { it to filesOf(it) }
|
||||
.map { (e, fs) -> toDto(e, fs) }
|
||||
|
||||
@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("Сдача не найдена") }
|
||||
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
||||
throw ForbiddenException("Нет доступа к этой сдаче")
|
||||
@@ -70,10 +110,32 @@ class SubmissionService(
|
||||
return perStudent.values.map { toDto(it, filesOf(it)) }
|
||||
}
|
||||
|
||||
private fun filesOf(s: Submission): List<String> =
|
||||
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
|
||||
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(
|
||||
id = s.id.toString(),
|
||||
seq = s.seq,
|
||||
|
||||
@@ -9,14 +9,29 @@ import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
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
|
||||
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)
|
||||
|
||||
fun store(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()
|
||||
fun store(
|
||||
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" }
|
||||
Files.createDirectories(dir)
|
||||
val names = mutableListOf<String>()
|
||||
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
|
||||
|
||||
fun listFiles(storedDir: Path): List<String> =
|
||||
if (Files.isDirectory(storedDir)) {
|
||||
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
|
||||
Files
|
||||
.list(storedDir)
|
||||
.map { it.fileName.toString() }
|
||||
.sorted()
|
||||
.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
|
||||
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
fun dirFor(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||
|
||||
@@ -20,4 +20,4 @@ class DefenseSession(
|
||||
@Column(name = "started_at") var startedAt: Instant? = null,
|
||||
@Column(name = "ready_at") var readyAt: Instant? = null,
|
||||
@Column(name = "ready_reason") var readyReason: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -21,4 +21,8 @@ class GradePublication(
|
||||
data class GradePublicationKey(
|
||||
val groupId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
) : Serializable {
|
||||
companion object {
|
||||
private const val serialVersionUID: Long = 1L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ class LabGrade(
|
||||
data class LabGradeKey(
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
) : Serializable {
|
||||
companion object {
|
||||
private const val serialVersionUID: Long = 1L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import java.util.UUID
|
||||
@Table(name = "student_lab_levels")
|
||||
@IdClass(StudentLabLevelKey::class)
|
||||
class StudentLabLevel(
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var studentId: 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 manual: Boolean = false,
|
||||
)
|
||||
|
||||
data class StudentLabLevelKey(
|
||||
val studentId: UUID = UUID(0,0),
|
||||
val labId: UUID = UUID(0,0),
|
||||
) : java.io.Serializable
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : java.io.Serializable
|
||||
|
||||
@@ -16,4 +16,4 @@ class Submission(
|
||||
@Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var seq: Int = 1,
|
||||
@Column(nullable = false) var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,9 +12,9 @@ import java.util.UUID
|
||||
@Table(name = "variant_assignments")
|
||||
class VariantAssignment(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false) var studentId: 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 studentId: 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 manual: Boolean = false,
|
||||
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,11 +7,20 @@ import java.util.UUID
|
||||
|
||||
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
||||
fun findByText(text: String): CommentTemplate?
|
||||
|
||||
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
||||
}
|
||||
|
||||
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
|
||||
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<GradeComment>
|
||||
|
||||
fun deleteByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
)
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<GradeComment>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@ import java.util.UUID
|
||||
|
||||
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
||||
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 findByStatus(status: String): List<DefenseSession>
|
||||
|
||||
fun findByCode(code: String): DefenseSession?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import java.util.UUID
|
||||
|
||||
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 findByGroupId(groupId: UUID): List<GradePublication>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import java.util.UUID
|
||||
|
||||
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||
fun findAllBy(): List<LabGrade>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<LabGrade>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): LabGrade?
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import java.util.UUID
|
||||
|
||||
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
|
||||
fun findByLabId(labId: 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@ import java.util.UUID
|
||||
|
||||
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
||||
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 findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
|
||||
}
|
||||
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): Submission?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import java.util.UUID
|
||||
|
||||
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
||||
fun findByStudentId(studentId: 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@@ -29,8 +27,11 @@ class SecurityConfig {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||
val role = jwt.getClaimAsString("role")
|
||||
if (role.isNullOrBlank()) emptyList()
|
||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
if (role.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
@@ -44,8 +45,7 @@ class SecurityConfig {
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
}.oauth2ResourceServer { oauth ->
|
||||
oauth.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
}
|
||||
@@ -56,4 +56,4 @@ class SecurityConfig {
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,17 @@ object DateUtils {
|
||||
private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||
|
||||
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
|
||||
fun formatMsk(value: String?): String? =
|
||||
toZoned(value)?.format(FORMAT)
|
||||
fun formatMsk(value: String?): String? = toZoned(value)?.format(FORMAT)
|
||||
|
||||
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
|
||||
fun localInput(value: String?): String? =
|
||||
toZoned(value)?.format(INPUT_FORMAT) ?: ""
|
||||
fun localInput(value: String?): String? = toZoned(value)?.format(INPUT_FORMAT) ?: ""
|
||||
|
||||
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
|
||||
fun toUtcIso(localValue: String?): String? =
|
||||
localValue?.takeIf { it.isNotBlank() }?.let { s ->
|
||||
try {
|
||||
LocalDateTime.parse(s, INPUT_FORMAT)
|
||||
LocalDateTime
|
||||
.parse(s, INPUT_FORMAT)
|
||||
.atZone(MSK)
|
||||
.toInstant()
|
||||
.toString()
|
||||
@@ -38,4 +37,4 @@ object DateUtils {
|
||||
} catch (_: Exception) {
|
||||
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
Reference in New Issue
Block a user