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`,
|
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
|
||||||
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||||
|
|
||||||
|
## Качество кода и тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
|
||||||
|
./gradlew ktlintFormat # автоформат Kotlin
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
|
||||||
|
Оба подключены во всех Kotlin-модулях конвенцией `lms.lint` и **ломают сборку** при замечаниях.
|
||||||
|
- Конфигурация: `.editorconfig`, `config/detekt/detekt.yml`.
|
||||||
|
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
|
||||||
|
не работает на JDK 25.
|
||||||
|
- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`).
|
||||||
|
|
||||||
## Запуск в Docker (для сервера)
|
## Запуск в Docker (для сервера)
|
||||||
|
|
||||||
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ dependencies {
|
|||||||
implementation(libs.kotlinAllopenPlugin)
|
implementation(libs.kotlinAllopenPlugin)
|
||||||
implementation(libs.kotlinNoargPlugin)
|
implementation(libs.kotlinNoargPlugin)
|
||||||
implementation(libs.springBootGradlePlugin)
|
implementation(libs.springBootGradlePlugin)
|
||||||
|
implementation(libs.ktlintGradlePlugin)
|
||||||
|
implementation(libs.detektGradlePlugin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ dependencyResolutionManagement {
|
|||||||
@Suppress("UnstableApiUsage")
|
@Suppress("UnstableApiUsage")
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the version catalog from the main build.
|
// Reuse the version catalog from the main build.
|
||||||
|
|||||||
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.spring")
|
||||||
id("org.jetbrains.kotlin.plugin.jpa")
|
id("org.jetbrains.kotlin.plugin.jpa")
|
||||||
id("org.springframework.boot")
|
id("org.springframework.boot")
|
||||||
|
id("lms.lint")
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "ru.ysu.lms"
|
group = "ru.ysu.lms"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
id("org.jetbrains.kotlin.plugin.spring")
|
id("org.jetbrains.kotlin.plugin.spring")
|
||||||
|
id("lms.lint")
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "ru.ysu.lms"
|
group = "ru.ysu.lms"
|
||||||
|
|||||||
71
config/detekt/detekt.yml
Normal file
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"
|
flexmark = "0.64.8"
|
||||||
owaspSanitizer = "20260313.1"
|
owaspSanitizer = "20260313.1"
|
||||||
apachePoi = "5.5.1"
|
apachePoi = "5.5.1"
|
||||||
|
ktlint = "14.2.0"
|
||||||
|
detekt = "1.23.8"
|
||||||
|
mockk = "1.14.11"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# --- Gradle plugins used from buildSrc ---
|
# --- Gradle plugins used from buildSrc ---
|
||||||
@@ -17,6 +20,8 @@ kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ver
|
|||||||
kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" }
|
kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" }
|
||||||
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
|
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
|
||||||
springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" }
|
springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" }
|
||||||
|
ktlintGradlePlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" }
|
||||||
|
detektGradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
|
||||||
|
|
||||||
# --- Platform BOMs (versions only) ---
|
# --- Platform BOMs (versions only) ---
|
||||||
springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }
|
springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }
|
||||||
@@ -65,6 +70,8 @@ owaspHtmlSanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-
|
|||||||
apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" }
|
apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" }
|
||||||
|
|
||||||
# --- Tests / Testcontainers (versions managed by the Boot BOM) ---
|
# --- Tests / Testcontainers (versions managed by the Boot BOM) ---
|
||||||
|
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||||
|
junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
|
||||||
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
|
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
|
||||||
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
|
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
|
||||||
testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
|
testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
|
||||||
@@ -78,3 +85,5 @@ kotlinPluginJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|||||||
kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
|
kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
|
||||||
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
|
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
|
||||||
springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" }
|
springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" }
|
||||||
|
ktlintPlugin = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||||
|
detektPlugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean
|
|||||||
|
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
class LmsCommonAutoConfiguration {
|
class LmsCommonAutoConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
|
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ open class LmsException(
|
|||||||
val violations: List<FieldViolation> = emptyList(),
|
val violations: List<FieldViolation> = emptyList(),
|
||||||
) : RuntimeException(message)
|
) : RuntimeException(message)
|
||||||
|
|
||||||
class BadRequestException(message: String, code: String? = null) :
|
class BadRequestException(
|
||||||
LmsException(HttpStatus.BAD_REQUEST, message, code)
|
message: String,
|
||||||
|
code: String? = null,
|
||||||
|
) : LmsException(HttpStatus.BAD_REQUEST, message, code)
|
||||||
|
|
||||||
class NotFoundException(message: String, code: String? = null) :
|
class NotFoundException(
|
||||||
LmsException(HttpStatus.NOT_FOUND, message, code)
|
message: String,
|
||||||
|
code: String? = null,
|
||||||
|
) : LmsException(HttpStatus.NOT_FOUND, message, code)
|
||||||
|
|
||||||
class ConflictException(message: String, code: String? = null) :
|
class ConflictException(
|
||||||
LmsException(HttpStatus.CONFLICT, message, code)
|
message: String,
|
||||||
|
code: String? = null,
|
||||||
|
) : LmsException(HttpStatus.CONFLICT, message, code)
|
||||||
|
|
||||||
class ForbiddenException(message: String, code: String? = null) :
|
class ForbiddenException(
|
||||||
LmsException(HttpStatus.FORBIDDEN, message, code)
|
message: String,
|
||||||
|
code: String? = null,
|
||||||
|
) : LmsException(HttpStatus.FORBIDDEN, message, code)
|
||||||
|
|
||||||
class UnauthorizedException(message: String, code: String? = null) :
|
class UnauthorizedException(
|
||||||
LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
message: String,
|
||||||
|
code: String? = null,
|
||||||
|
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
|||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class LmsExceptionHandler {
|
class LmsExceptionHandler {
|
||||||
|
|
||||||
@ExceptionHandler(LmsException::class)
|
@ExceptionHandler(LmsException::class)
|
||||||
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
|
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
|
||||||
ResponseEntity.status(ex.status).body(
|
ResponseEntity.status(ex.status).body(
|
||||||
@@ -22,7 +21,8 @@ class LmsExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||||
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
|
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
|
||||||
val violations = ex.bindingResult.fieldErrors.map {
|
val violations =
|
||||||
|
ex.bindingResult.fieldErrors.map {
|
||||||
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(
|
return ResponseEntity.badRequest().body(
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ dependencies {
|
|||||||
api(libs.owaspHtmlSanitizer)
|
api(libs.owaspHtmlSanitizer)
|
||||||
api(libs.springContext)
|
api(libs.springContext)
|
||||||
api(libs.springBootAutoconfigure)
|
api(libs.springBootAutoconfigure)
|
||||||
|
|
||||||
|
testImplementation(project(":libs:test-support"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
|
|||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@EnableConfigurationProperties(MarkdownProperties::class)
|
@EnableConfigurationProperties(MarkdownProperties::class)
|
||||||
class LmsMarkdownAutoConfiguration {
|
class LmsMarkdownAutoConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
|
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
|
||||||
MarkdownRenderer(allowHtml = properties.allowHtml)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import org.owasp.html.PolicyFactory
|
|||||||
class MarkdownRenderer(
|
class MarkdownRenderer(
|
||||||
private val allowHtml: Boolean = false,
|
private val allowHtml: Boolean = false,
|
||||||
) {
|
) {
|
||||||
private val options = MutableDataSet().apply {
|
private val options =
|
||||||
|
MutableDataSet().apply {
|
||||||
set(
|
set(
|
||||||
Parser.EXTENSIONS,
|
Parser.EXTENSIONS,
|
||||||
listOf(
|
listOf(
|
||||||
@@ -55,17 +56,22 @@ class MarkdownRenderer(
|
|||||||
|
|
||||||
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
|
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
|
||||||
private fun wrapTables(html: String): String =
|
private fun wrapTables(html: String): String =
|
||||||
html.replace("<table", "<div class=\"table-scroll\"><table")
|
html
|
||||||
|
.replace("<table", "<div class=\"table-scroll\"><table")
|
||||||
.replace("</table>", "</table></div>")
|
.replace("</table>", "</table></div>")
|
||||||
|
|
||||||
private fun renderRaw(markdown: String, depth: Int): String {
|
private fun renderRaw(
|
||||||
|
markdown: String,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
val out = StringBuilder()
|
val out = StringBuilder()
|
||||||
var cursor = 0
|
var cursor = 0
|
||||||
for (match in SPOILER.findAll(markdown)) {
|
for (match in SPOILER.findAll(markdown)) {
|
||||||
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
|
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
|
||||||
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
|
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
|
||||||
val body = match.groupValues[2]
|
val body = match.groupValues[2]
|
||||||
val inner = if (depth < MAX_DEPTH) {
|
val inner =
|
||||||
|
if (depth < MAX_DEPTH) {
|
||||||
renderRaw(body, depth + 1)
|
renderRaw(body, depth + 1)
|
||||||
} else {
|
} else {
|
||||||
renderWithDirectives(body)
|
renderWithDirectives(body)
|
||||||
@@ -81,8 +87,7 @@ class MarkdownRenderer(
|
|||||||
return out.toString()
|
return out.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderSegment(markdown: String): String =
|
private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
|
||||||
renderer.render(parser.parse(markdown))
|
|
||||||
|
|
||||||
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
|
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
|
||||||
private fun renderWithDirectives(markdown: String): String {
|
private fun renderWithDirectives(markdown: String): String {
|
||||||
@@ -118,50 +123,113 @@ class MarkdownRenderer(
|
|||||||
return out.toString()
|
return out.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderInline(text: String): String =
|
private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||||
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
|
||||||
|
|
||||||
private fun escapeHtml(value: String): String = value
|
private fun escapeHtml(value: String): String =
|
||||||
|
value
|
||||||
.replace("&", "&")
|
.replace("&", "&")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
.replace(">", ">")
|
.replace(">", ">")
|
||||||
.replace("\"", """)
|
.replace("\"", """)
|
||||||
|
|
||||||
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
|
private fun buildSanitizer(): PolicyFactory =
|
||||||
.allowElements(
|
HtmlPolicyBuilder()
|
||||||
"p", "br", "hr",
|
.allowElements(*ALLOWED_ELEMENTS)
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
.allowAttributes("href")
|
||||||
"strong", "b", "em", "i", "del", "s", "blockquote",
|
.onElements("a")
|
||||||
"ul", "ol", "li",
|
.allowAttributes("title")
|
||||||
"a", "img",
|
.onElements("a", "img")
|
||||||
"code", "pre",
|
.allowAttributes("src", "alt")
|
||||||
"table", "thead", "tbody", "tr", "th", "td",
|
.onElements("img")
|
||||||
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
|
|
||||||
"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")
|
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||||
.onElements("video")
|
.onElements("video")
|
||||||
.allowAttributes("src", "type").onElements("source")
|
.allowAttributes("src", "type")
|
||||||
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
|
.onElements("source")
|
||||||
.allowAttributes("class").onElements(
|
.allowAttributes("src", "kind", "label", "srclang", "default")
|
||||||
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
|
.onElements("track")
|
||||||
"figure", "figcaption", "video", "img",
|
.allowAttributes("class")
|
||||||
)
|
.onElements(*CLASS_ATTR_ELEMENTS)
|
||||||
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
.allowAttributes("id")
|
||||||
.allowAttributes("colspan", "rowspan").onElements("td", "th")
|
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||||
|
.allowAttributes("colspan", "rowspan")
|
||||||
|
.onElements("td", "th")
|
||||||
.allowUrlProtocols("http", "https", "mailto")
|
.allowUrlProtocols("http", "https", "mailto")
|
||||||
.requireRelNofollowOnLinks()
|
.requireRelNofollowOnLinks()
|
||||||
.toFactory()
|
.toFactory()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAX_DEPTH = 5
|
const val MAX_DEPTH = 5
|
||||||
val MEDIA = Regex(
|
|
||||||
|
/** Теги, разрешённые в отрендеренном 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]*$",
|
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
|
||||||
)
|
)
|
||||||
val SPOILER = Regex(
|
val SPOILER =
|
||||||
|
Regex(
|
||||||
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
|
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 {
|
enum class LmsRole {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
STUDENT;
|
STUDENT,
|
||||||
|
;
|
||||||
|
|
||||||
fun authority(): String = "ROLE_$name"
|
fun authority(): String = "ROLE_$name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
|||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@EnableConfigurationProperties(JwtProperties::class)
|
@EnableConfigurationProperties(JwtProperties::class)
|
||||||
class LmsSecurityAutoConfiguration {
|
class LmsSecurityAutoConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
|
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
|
||||||
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
|
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
|
||||||
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
|
val rsaKey =
|
||||||
|
RSAKey
|
||||||
|
.Builder(RsaKeys.publicKey(properties.publicKey))
|
||||||
.privateKey(RsaKeys.privateKey(properties.privateKey))
|
.privateKey(RsaKeys.privateKey(properties.privateKey))
|
||||||
.build()
|
.build()
|
||||||
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||||
|
|||||||
@@ -9,21 +9,23 @@ import java.util.Base64
|
|||||||
|
|
||||||
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
|
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
|
||||||
object RsaKeys {
|
object RsaKeys {
|
||||||
|
|
||||||
fun publicKey(pem: String): RSAPublicKey {
|
fun publicKey(pem: String): RSAPublicKey {
|
||||||
val der = decode(pem)
|
val der = decode(pem)
|
||||||
return KeyFactory.getInstance("RSA")
|
return KeyFactory
|
||||||
|
.getInstance("RSA")
|
||||||
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
|
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
fun privateKey(pem: String): RSAPrivateKey {
|
fun privateKey(pem: String): RSAPrivateKey {
|
||||||
val der = decode(pem)
|
val der = decode(pem)
|
||||||
return KeyFactory.getInstance("RSA")
|
return KeyFactory
|
||||||
|
.getInstance("RSA")
|
||||||
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
|
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decode(pem: String): ByteArray {
|
private fun decode(pem: String): ByteArray {
|
||||||
val body = pem
|
val body =
|
||||||
|
pem
|
||||||
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
|
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
|
||||||
.replace(Regex("-----END [A-Z ]+-----"), "")
|
.replace(Regex("-----END [A-Z ]+-----"), "")
|
||||||
.replace(Regex("\\s"), "")
|
.replace(Regex("\\s"), "")
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.springBootBom))
|
api(platform(libs.springBootBom))
|
||||||
|
|
||||||
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
|
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
|
||||||
api(libs.springBootTestcontainers)
|
api(libs.springBootTestcontainers)
|
||||||
api(libs.testcontainersPostgresql)
|
api(libs.testcontainersPostgresql)
|
||||||
api(libs.testcontainersJunitJupiter)
|
api(libs.testcontainersJunitJupiter)
|
||||||
api(libs.springBootStarterTest)
|
api(libs.springBootStarterTest)
|
||||||
|
api(libs.mockk)
|
||||||
|
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
|
||||||
|
api(libs.junitPlatformLauncher)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,30 +8,29 @@ import java.nio.file.Path
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
|
class AssetStorage(
|
||||||
|
@Value("\${lms.content.assets-dir:/data/content-assets}") root: String,
|
||||||
|
) {
|
||||||
private val rootDir: Path = Path.of(root)
|
private val rootDir: Path = Path.of(root)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Files.createDirectories(rootDir)
|
Files.createDirectories(rootDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
|
data class StoredAsset(
|
||||||
|
val publicName: String,
|
||||||
|
val storedPath: Path,
|
||||||
|
val mime: String,
|
||||||
|
)
|
||||||
|
|
||||||
fun store(originalName: String, bytes: ByteArray): StoredAsset {
|
fun store(
|
||||||
|
originalName: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): StoredAsset {
|
||||||
val ext = originalName.substringAfterLast('.', "").lowercase()
|
val ext = originalName.substringAfterLast('.', "").lowercase()
|
||||||
val isVideo = ext in VIDEO_EXTENSIONS
|
val isVideo = ext in VIDEO_EXTENSIONS
|
||||||
val isImage = ext in IMAGE_EXTENSIONS
|
requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS)
|
||||||
if (!isVideo && !isImage) {
|
requireSizeWithinLimit(originalName, bytes.size, isVideo)
|
||||||
throw BadRequestException(
|
|
||||||
"Недопустимый тип файла: $originalName " +
|
|
||||||
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
|
|
||||||
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
|
|
||||||
if (bytes.size > limit) {
|
|
||||||
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
|
|
||||||
}
|
|
||||||
val publicName = "${UUID.randomUUID()}.$ext"
|
val publicName = "${UUID.randomUUID()}.$ext"
|
||||||
val target = rootDir.resolve(publicName).normalize()
|
val target = rootDir.resolve(publicName).normalize()
|
||||||
if (!target.startsWith(rootDir)) {
|
if (!target.startsWith(rootDir)) {
|
||||||
@@ -41,6 +40,31 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
|
|||||||
return StoredAsset(publicName, target, mimeFor(ext))
|
return StoredAsset(publicName, target, mimeFor(ext))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requireAllowedType(
|
||||||
|
originalName: String,
|
||||||
|
isVideo: Boolean,
|
||||||
|
isImage: Boolean,
|
||||||
|
) {
|
||||||
|
if (!isVideo && !isImage) {
|
||||||
|
throw BadRequestException(
|
||||||
|
"Недопустимый тип файла: $originalName " +
|
||||||
|
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
|
||||||
|
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireSizeWithinLimit(
|
||||||
|
originalName: String,
|
||||||
|
size: Int,
|
||||||
|
isVideo: Boolean,
|
||||||
|
) {
|
||||||
|
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
|
||||||
|
if (size > limit) {
|
||||||
|
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun read(publicName: String): Pair<Path, String>? {
|
fun read(publicName: String): Pair<Path, String>? {
|
||||||
val clean = publicName.substringAfterLast('/')
|
val clean = publicName.substringAfterLast('/')
|
||||||
val target = rootDir.resolve(clean).normalize()
|
val target = rootDir.resolve(clean).normalize()
|
||||||
@@ -50,7 +74,8 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
|
|||||||
return target to mimeFor(ext)
|
return target to mimeFor(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mimeFor(ext: String): String = when (ext) {
|
private fun mimeFor(ext: String): String =
|
||||||
|
when (ext) {
|
||||||
"png" -> "image/png"
|
"png" -> "image/png"
|
||||||
"jpg", "jpeg" -> "image/jpeg"
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
"gif" -> "image/gif"
|
"gif" -> "image/gif"
|
||||||
|
|||||||
@@ -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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import ru.ysu.lms.academic.domain.ContentArticle
|
|
||||||
import ru.ysu.lms.academic.domain.ContentChapter
|
|
||||||
import ru.ysu.lms.academic.domain.ContentSection
|
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
import ru.ysu.lms.common.BadRequestException
|
|
||||||
import ru.ysu.lms.common.NotFoundException
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
|
|
||||||
data class ArticleTreeNode(
|
data class ArticleTreeNode(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -40,7 +34,9 @@ data class ChapterTreeNode(
|
|||||||
val sections: List<SectionTreeNode>,
|
val sections: List<SectionTreeNode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ContentTree(val chapters: List<ChapterTreeNode>)
|
data class ContentTree(
|
||||||
|
val chapters: List<ChapterTreeNode>,
|
||||||
|
)
|
||||||
|
|
||||||
data class ContentArticleView(
|
data class ContentArticleView(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -68,27 +64,32 @@ data class ContentImportResult(
|
|||||||
* (и перенос разделов между главами), порядок статей внутри разделов
|
* (и перенос разделов между главами), порядок статей внутри разделов
|
||||||
* (и перенос статей между разделами). Применяется одним запросом.
|
* (и перенос статей между разделами). Применяется одним запросом.
|
||||||
*/
|
*/
|
||||||
data class ReorderArticle(val id: String)
|
data class ReorderArticle(
|
||||||
data class ReorderSection(val id: String, val articles: List<ReorderArticle> = emptyList())
|
val id: String,
|
||||||
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
|
)
|
||||||
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
|
|
||||||
|
data class ReorderSection(
|
||||||
|
val id: String,
|
||||||
|
val articles: List<ReorderArticle> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReorderChapter(
|
||||||
|
val id: String,
|
||||||
|
val sections: List<ReorderSection> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReorderTree(
|
||||||
|
val chapters: List<ReorderChapter> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
|
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
|
||||||
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
|
data class ReorderSections(
|
||||||
|
val sections: List<ReorderSection> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||||
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList())
|
data class ReorderArticles(
|
||||||
|
val articles: List<ReorderArticle> = emptyList(),
|
||||||
private data class ParsedArticle(
|
|
||||||
val path: String,
|
|
||||||
val chapterPos: Int,
|
|
||||||
val chapterDir: String,
|
|
||||||
val sectionPos: Int,
|
|
||||||
val sectionDir: String,
|
|
||||||
val articlePos: Int,
|
|
||||||
val fileTitle: String,
|
|
||||||
val fmSlug: String?,
|
|
||||||
val body: String,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -96,16 +97,19 @@ class ContentService(
|
|||||||
private val chapters: ContentChapterRepository,
|
private val chapters: ContentChapterRepository,
|
||||||
private val sections: ContentSectionRepository,
|
private val sections: ContentSectionRepository,
|
||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
private val assets: AssetStorage,
|
private val importer: ContentImporter,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
val sectionList =
|
||||||
|
sections
|
||||||
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
.filter { includeHidden || it.published }
|
.filter { includeHidden || it.published }
|
||||||
.groupBy { it.chapterId }
|
.groupBy { it.chapterId }
|
||||||
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
val articleList =
|
||||||
|
articles
|
||||||
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
.filter { includeHidden || it.published }
|
.filter { includeHidden || it.published }
|
||||||
.groupBy { it.sectionId }
|
.groupBy { it.sectionId }
|
||||||
return ContentTree(
|
return ContentTree(
|
||||||
@@ -116,14 +120,16 @@ class ContentService(
|
|||||||
slug = ch.slug,
|
slug = ch.slug,
|
||||||
position = ch.position,
|
position = ch.position,
|
||||||
published = ch.published,
|
published = ch.published,
|
||||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
sections =
|
||||||
|
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||||
SectionTreeNode(
|
SectionTreeNode(
|
||||||
id = sec.id.toString(),
|
id = sec.id.toString(),
|
||||||
title = sec.title,
|
title = sec.title,
|
||||||
slug = sec.slug,
|
slug = sec.slug,
|
||||||
position = sec.position,
|
position = sec.position,
|
||||||
published = sec.published,
|
published = sec.published,
|
||||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
articles =
|
||||||
|
(articleList[sec.id] ?: emptyList()).map { a ->
|
||||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -144,12 +150,12 @@ class ContentService(
|
|||||||
articleSlug: String,
|
articleSlug: String,
|
||||||
includeHidden: Boolean = false,
|
includeHidden: Boolean = false,
|
||||||
): ContentArticleView {
|
): ContentArticleView {
|
||||||
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
|
||||||
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
|
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
||||||
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт")
|
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
||||||
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
||||||
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
|
requireVisible(a.published, includeHidden, "Статья скрыта")
|
||||||
return ContentArticleView(
|
return ContentArticleView(
|
||||||
id = a.id.toString(),
|
id = a.id.toString(),
|
||||||
chapterSlug = ch.slug,
|
chapterSlug = ch.slug,
|
||||||
@@ -163,84 +169,44 @@ class ContentService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */
|
private fun <T> requireFound(
|
||||||
@Transactional
|
value: T?,
|
||||||
fun reorder(tree: ReorderTree) {
|
message: String,
|
||||||
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
|
): T = value ?: throw NotFoundException(message)
|
||||||
val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
|
|
||||||
?: return@forEachIndexed
|
|
||||||
chapter.position = chapterIndex + 1
|
|
||||||
chapters.save(chapter)
|
|
||||||
|
|
||||||
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto ->
|
private fun requireVisible(
|
||||||
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
|
published: Boolean,
|
||||||
?: return@forEachIndexed
|
includeHidden: Boolean,
|
||||||
section.chapterId = chapter.id
|
message: String,
|
||||||
section.position = sectionIndex + 1
|
) {
|
||||||
sections.save(section)
|
if (!includeHidden && !published) throw NotFoundException(message)
|
||||||
|
|
||||||
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
|
|
||||||
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
|
||||||
?: return@forEachIndexed
|
|
||||||
article.sectionId = section.id
|
|
||||||
article.position = articleIndex + 1
|
|
||||||
articles.save(article)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
|
|
||||||
@Transactional
|
|
||||||
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
|
@Transactional
|
||||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
fun setChapterPublished(
|
||||||
|
chapterId: UUID,
|
||||||
|
published: Boolean,
|
||||||
|
) {
|
||||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
ch.published = published
|
ch.published = published
|
||||||
chapters.save(ch)
|
chapters.save(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setSectionPublished(sectionId: UUID, published: Boolean) {
|
fun setSectionPublished(
|
||||||
|
sectionId: UUID,
|
||||||
|
published: Boolean,
|
||||||
|
) {
|
||||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
sec.published = published
|
sec.published = published
|
||||||
sections.save(sec)
|
sections.save(sec)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setArticlePublished(articleId: UUID, published: Boolean) {
|
fun setArticlePublished(
|
||||||
|
articleId: UUID,
|
||||||
|
published: Boolean,
|
||||||
|
) {
|
||||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||||
a.published = published
|
a.published = published
|
||||||
articles.save(a)
|
articles.save(a)
|
||||||
@@ -267,400 +233,9 @@ class ContentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
|
fun importZip(
|
||||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
filename: String,
|
||||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
bytes: ByteArray,
|
||||||
a.title = title.trim()
|
sync: Boolean = true,
|
||||||
a.bodyMd = bodyMd
|
): ContentImportResult = importer.importZip(filename, bytes, sync)
|
||||||
articles.save(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteArticle(articleId: UUID) {
|
|
||||||
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
|
||||||
articles.deleteById(articleId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun createChapter(title: String, slug: String?): ChapterTreeNode {
|
|
||||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
|
||||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
|
||||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
|
||||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
|
||||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
|
||||||
return ChapterTreeNode(
|
|
||||||
id = ch.id.toString(), title = ch.title, slug = ch.slug,
|
|
||||||
position = ch.position, published = ch.published, sections = emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun renameChapter(chapterId: UUID, title: String) {
|
|
||||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
|
||||||
ch.title = title.trim()
|
|
||||||
chapters.save(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteChapter(chapterId: UUID) {
|
|
||||||
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
|
||||||
chapters.deleteById(chapterId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
|
|
||||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
|
||||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
|
||||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
|
||||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
|
||||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
|
||||||
val sec = sections.save(
|
|
||||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
|
||||||
)
|
|
||||||
return SectionTreeNode(
|
|
||||||
id = sec.id.toString(), title = sec.title, slug = sec.slug,
|
|
||||||
position = sec.position, published = sec.published, articles = emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun renameSection(sectionId: UUID, title: String) {
|
|
||||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
|
||||||
sec.title = title.trim()
|
|
||||||
sections.save(sec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteSection(sectionId: UUID) {
|
|
||||||
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
|
||||||
sections.deleteById(sectionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
|
||||||
@Transactional
|
|
||||||
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
|
|
||||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
|
||||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
|
||||||
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
|
||||||
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
|
||||||
for (a in moving) {
|
|
||||||
pos += 1
|
|
||||||
a.sectionId = dst.id
|
|
||||||
a.position = pos
|
|
||||||
articles.save(a)
|
|
||||||
}
|
|
||||||
sections.delete(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
|
||||||
@Transactional
|
|
||||||
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
|
||||||
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
|
||||||
val targetSection: ContentSection = when {
|
|
||||||
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
|
|
||||||
val anchor = sections.findById(sectionId)
|
|
||||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
val node = createSection(anchor.chapterId, newSectionTitle, null)
|
|
||||||
sections.findById(UUID.fromString(node.id))
|
|
||||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
}
|
|
||||||
sectionId != null -> sections.findById(sectionId)
|
|
||||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
!newSectionTitle.isNullOrBlank() -> {
|
|
||||||
val chapter: ContentChapter = when {
|
|
||||||
chapterId != null -> chapters.findById(chapterId)
|
|
||||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
!newChapterTitle.isNullOrBlank() -> {
|
|
||||||
val node = createChapter(newChapterTitle, null)
|
|
||||||
chapters.findById(UUID.fromString(node.id))
|
|
||||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
}
|
|
||||||
else -> throw BadRequestException("Укажите главу")
|
|
||||||
}
|
|
||||||
val node = createSection(chapter.id, newSectionTitle, null)
|
|
||||||
sections.findById(UUID.fromString(node.id))
|
|
||||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
}
|
|
||||||
else -> throw BadRequestException("Укажите раздел")
|
|
||||||
}
|
|
||||||
return createArticle(targetSection.id, title, bodyMd)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
|
|
||||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
|
||||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
|
||||||
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
|
||||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
|
||||||
val a = articles.save(
|
|
||||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
|
||||||
)
|
|
||||||
return ContentArticleView(
|
|
||||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
|
||||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
|
||||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
|
|
||||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
|
||||||
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
|
||||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
|
||||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
|
||||||
a.sectionId = sec.id
|
|
||||||
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
|
||||||
articles.save(a)
|
|
||||||
return ContentArticleView(
|
|
||||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
|
||||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
|
||||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun uniqueSlug(title: String, taken: Set<String>): String {
|
|
||||||
val base = slugify(title) ?: "item"
|
|
||||||
if (base !in taken) return base
|
|
||||||
var i = 2
|
|
||||||
while ("$base-$i" in taken) i += 1
|
|
||||||
return "$base-$i"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Импорт zip со структурой каталогов:
|
|
||||||
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
|
|
||||||
* картинки в assets/.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
|
|
||||||
if (!filename.lowercase().endsWith(".zip")) {
|
|
||||||
throw BadRequestException("Ожидается .zip архив")
|
|
||||||
}
|
|
||||||
val files = readZip(bytes)
|
|
||||||
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
|
||||||
|
|
||||||
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
|
|
||||||
val contentFiles = files.filterKeys { k ->
|
|
||||||
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
|
||||||
}
|
|
||||||
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
|
||||||
|
|
||||||
val linkRewrite = mutableMapOf<String, String>()
|
|
||||||
for ((path, data) in assetFiles) {
|
|
||||||
val name = path.substringAfterLast('/')
|
|
||||||
if (name.isBlank()) continue
|
|
||||||
val stored = assets.store(name, data)
|
|
||||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
|
||||||
}
|
|
||||||
|
|
||||||
val parsed = contentFiles.map { (path, data) ->
|
|
||||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
|
||||||
val parts = clean.split('/')
|
|
||||||
if (parts.size < 3) {
|
|
||||||
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
|
||||||
}
|
|
||||||
val chapterDir = parts[0]
|
|
||||||
val sectionDir = parts[1]
|
|
||||||
val fileName = parts.drop(2).joinToString("-")
|
|
||||||
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
|
||||||
ParsedArticle(
|
|
||||||
path = path,
|
|
||||||
chapterPos = positionOf(chapterDir),
|
|
||||||
chapterDir = titleFromDir(chapterDir),
|
|
||||||
sectionPos = positionOf(sectionDir),
|
|
||||||
sectionDir = titleFromDir(sectionDir),
|
|
||||||
articlePos = positionOf(fileName),
|
|
||||||
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
|
||||||
fmSlug = parsedContent.first["slug"],
|
|
||||||
body = parsedContent.second,
|
|
||||||
)
|
|
||||||
}.sortedWith(
|
|
||||||
compareBy(
|
|
||||||
{ it.chapterPos },
|
|
||||||
{ it.chapterDir },
|
|
||||||
{ it.sectionPos },
|
|
||||||
{ it.sectionDir },
|
|
||||||
{ it.articlePos },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
var chCount = 0
|
|
||||||
var secCount = 0
|
|
||||||
var artCount = 0
|
|
||||||
var removedCount = 0
|
|
||||||
var currentChapter: ContentChapter? = null
|
|
||||||
var currentSection: ContentSection? = null
|
|
||||||
val touchedChapters = mutableSetOf<UUID>()
|
|
||||||
val seenSections = mutableSetOf<UUID>()
|
|
||||||
val seenArticles = mutableSetOf<UUID>()
|
|
||||||
|
|
||||||
for (p in parsed) {
|
|
||||||
if (currentChapter?.title != p.chapterDir) {
|
|
||||||
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
|
|
||||||
// Ищем по названию, затем по slug: так импорт находит главу, созданную
|
|
||||||
// вручную или импортированную ранее, и не плодит дубли.
|
|
||||||
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
|
|
||||||
title = p.chapterDir
|
|
||||||
position = p.chapterPos
|
|
||||||
} ?: chapters.save(
|
|
||||||
ContentChapter(
|
|
||||||
title = p.chapterDir,
|
|
||||||
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
|
||||||
position = p.chapterPos,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
currentChapter = chapters.save(currentChapter!!)
|
|
||||||
touchedChapters.add(currentChapter!!.id)
|
|
||||||
chCount += 1
|
|
||||||
currentSection = null
|
|
||||||
}
|
|
||||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
|
||||||
if (currentSection?.title != p.sectionDir) {
|
|
||||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
|
||||||
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
|
||||||
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
|
|
||||||
title = p.sectionDir
|
|
||||||
position = p.sectionPos
|
|
||||||
} ?: sections.save(
|
|
||||||
ContentSection(
|
|
||||||
chapterId = chapter.id,
|
|
||||||
title = p.sectionDir,
|
|
||||||
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
|
|
||||||
position = p.sectionPos,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
currentSection = sections.save(currentSection!!)
|
|
||||||
seenSections.add(currentSection!!.id)
|
|
||||||
secCount += 1
|
|
||||||
}
|
|
||||||
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
|
||||||
|
|
||||||
var body = p.body
|
|
||||||
for ((from, to) in linkRewrite) {
|
|
||||||
body = body.replace(from, to)
|
|
||||||
}
|
|
||||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
|
|
||||||
?: articleSlug(p)
|
|
||||||
?: "article-${artCount + 1}"
|
|
||||||
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
|
|
||||||
?: articles.findBySectionIdAndSlug(section.id, slug)
|
|
||||||
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
|
||||||
title = p.fileTitle
|
|
||||||
bodyMd = body
|
|
||||||
position = p.articlePos
|
|
||||||
}
|
|
||||||
articles.save(article)
|
|
||||||
seenArticles.add(article.id)
|
|
||||||
artCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
|
|
||||||
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
|
|
||||||
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
|
|
||||||
for (chapterId in if (sync) touchedChapters else emptySet()) {
|
|
||||||
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
|
|
||||||
if (section.id in seenSections) {
|
|
||||||
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
|
||||||
if (article.id !in seenArticles) {
|
|
||||||
articles.delete(article)
|
|
||||||
removedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
|
|
||||||
sections.delete(section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ContentImportResult(
|
|
||||||
chapters = chCount,
|
|
||||||
sections = secCount,
|
|
||||||
articles = artCount,
|
|
||||||
assets = linkRewrite.size,
|
|
||||||
removed = removedCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
|
|
||||||
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
|
|
||||||
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
|
|
||||||
|
|
||||||
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
|
|
||||||
|
|
||||||
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
|
|
||||||
|
|
||||||
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
|
|
||||||
val result = mutableMapOf<String, ByteArray>()
|
|
||||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
|
||||||
var entry = zis.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
if (!entry.isDirectory) {
|
|
||||||
result[entry.name.removePrefix("/")] = zis.readAllBytes()
|
|
||||||
}
|
|
||||||
entry = zis.nextEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
|
||||||
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
|
||||||
val lines = content.lines()
|
|
||||||
val end = lines.withIndex()
|
|
||||||
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
|
||||||
?.index ?: return emptyMap<String, String>() to content
|
|
||||||
val fm = mutableMapOf<String, String>()
|
|
||||||
for (line in lines.subList(1, end)) {
|
|
||||||
val parts = line.split(":", limit = 2)
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val v = parts[1].trim()
|
|
||||||
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
|
||||||
v.substring(1, v.length - 1)
|
|
||||||
} else {
|
|
||||||
v
|
|
||||||
}
|
|
||||||
fm[parts[0].trim()] = unquoted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fm to lines.drop(end + 1).joinToString("\n").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun positionOf(name: String): Int {
|
|
||||||
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
|
||||||
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun titleFromDir(dir: String): String {
|
|
||||||
val raw = dir.substringAfterLast('/')
|
|
||||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
|
||||||
.replace('_', ' ')
|
|
||||||
return raw.ifBlank { dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun slugify(value: String): String? {
|
|
||||||
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
|
||||||
val slug = translit
|
|
||||||
.replace(Regex("\\s+"), "-")
|
|
||||||
.replace(Regex("[^a-z0-9-]"), "")
|
|
||||||
.replace(Regex("-+"), "-")
|
|
||||||
.trim('-')
|
|
||||||
return slug.ifBlank { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val TRANSLIT = mapOf(
|
|
||||||
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
|
|
||||||
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
|
|
||||||
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
|
|
||||||
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
|
|
||||||
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import ru.ysu.lms.academic.content.ArticlePlacement
|
||||||
|
import ru.ysu.lms.academic.content.ContentAuthoringService
|
||||||
import ru.ysu.lms.academic.content.ContentImportResult
|
import ru.ysu.lms.academic.content.ContentImportResult
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
import ru.ysu.lms.academic.content.ReorderArticles
|
|
||||||
import ru.ysu.lms.academic.content.ReorderSections
|
|
||||||
import ru.ysu.lms.academic.content.ReorderTree
|
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/content")
|
@RequestMapping("/api/v1/content")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
class ContentAdminController(private val contentService: ContentService) {
|
class ContentAdminController(
|
||||||
|
private val contentService: ContentService,
|
||||||
|
private val authoring: ContentAuthoringService,
|
||||||
|
) {
|
||||||
|
data class ArticleUpdatePayload(
|
||||||
|
val title: String,
|
||||||
|
val bodyMd: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
|
|
||||||
data class ArticleCreatePayload(
|
data class ArticleCreatePayload(
|
||||||
val chapterId: String? = null,
|
val chapterId: String? = null,
|
||||||
val sectionId: String? = null,
|
val sectionId: String? = null,
|
||||||
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
val title: String,
|
val title: String,
|
||||||
val bodyMd: String = "",
|
val bodyMd: String = "",
|
||||||
)
|
)
|
||||||
data class ArticleMovePayload(val sectionId: String)
|
|
||||||
data class TitlePayload(val title: String)
|
data class ArticleMovePayload(
|
||||||
data class PublishedPayload(val published: Boolean)
|
val sectionId: String,
|
||||||
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
)
|
||||||
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
|
||||||
|
data class TitlePayload(
|
||||||
|
val title: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PublishedPayload(
|
||||||
|
val published: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChapterCreatePayload(
|
||||||
|
val title: String,
|
||||||
|
val slug: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SectionCreatePayload(
|
||||||
|
val title: String,
|
||||||
|
val slug: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** Полное дерево материалов, включая скрытые (для админки). */
|
/** Полное дерево материалов, включая скрытые (для админки). */
|
||||||
@GetMapping("/tree/all")
|
@GetMapping("/tree/all")
|
||||||
@@ -53,45 +75,42 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
): ContentPublicController.ContentArticleViewDto {
|
): ContentPublicController.ContentArticleViewDto {
|
||||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||||
return ContentPublicController.ContentArticleViewDto(
|
return ContentPublicController.ContentArticleViewDto(
|
||||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
id = a.id,
|
||||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
chapterSlug = a.chapterSlug,
|
||||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
chapterTitle = a.chapterTitle,
|
||||||
|
sectionSlug = a.sectionSlug,
|
||||||
|
sectionTitle = a.sectionTitle,
|
||||||
|
slug = a.slug,
|
||||||
|
title = a.title,
|
||||||
|
bodyMd = a.bodyMd,
|
||||||
|
published = a.published,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/chapters/{id}/published")
|
@PutMapping("/chapters/{id}/published")
|
||||||
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
fun chapterPublished(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: PublishedPayload,
|
||||||
|
) {
|
||||||
contentService.setChapterPublished(id, body.published)
|
contentService.setChapterPublished(id, body.published)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/sections/{id}/published")
|
@PutMapping("/sections/{id}/published")
|
||||||
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
fun sectionPublished(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: PublishedPayload,
|
||||||
|
) {
|
||||||
contentService.setSectionPublished(id, body.published)
|
contentService.setSectionPublished(id, body.published)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/articles/{id}/published")
|
@PutMapping("/articles/{id}/published")
|
||||||
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
fun articlePublished(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: PublishedPayload,
|
||||||
|
) {
|
||||||
contentService.setArticlePublished(id, body.published)
|
contentService.setArticlePublished(id, body.published)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
|
|
||||||
@PostMapping("/reorder")
|
|
||||||
fun reorder(@RequestBody body: ReorderTree) {
|
|
||||||
contentService.reorder(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Порядок разделов внутри главы (drag & drop на странице главы). */
|
|
||||||
@PostMapping("/chapters/{id}/sections/reorder")
|
|
||||||
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) {
|
|
||||||
contentService.reorderSections(id, body.sections)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
|
|
||||||
@PostMapping("/sections/{id}/articles/reorder")
|
|
||||||
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) {
|
|
||||||
contentService.reorderArticles(id, body.articles)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
@@ -105,25 +124,35 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
fun list() = contentService.listAll()
|
fun list() = contentService.listAll()
|
||||||
|
|
||||||
@PostMapping("/chapters")
|
@PostMapping("/chapters")
|
||||||
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
|
fun createChapter(
|
||||||
|
@RequestBody body: ChapterCreatePayload,
|
||||||
|
) = authoring.createChapter(body.title, body.slug)
|
||||||
|
|
||||||
@PutMapping("/chapters/{id}")
|
@PutMapping("/chapters/{id}")
|
||||||
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
fun renameChapter(
|
||||||
contentService.renameChapter(id, body.title)
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: TitlePayload,
|
||||||
|
) = authoring.renameChapter(id, body.title)
|
||||||
|
|
||||||
@DeleteMapping("/chapters/{id}")
|
@DeleteMapping("/chapters/{id}")
|
||||||
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
|
fun deleteChapter(
|
||||||
contentService.deleteChapter(id)
|
@PathVariable id: UUID,
|
||||||
|
): ResponseEntity<Void> {
|
||||||
|
authoring.deleteChapter(id)
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/sections")
|
@PostMapping("/sections")
|
||||||
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
|
fun createSection(
|
||||||
contentService.createSection(chapterId, body.title, body.slug)
|
@RequestParam chapterId: UUID,
|
||||||
|
@RequestBody body: SectionCreatePayload,
|
||||||
|
) = authoring.createSection(chapterId, body.title, body.slug)
|
||||||
|
|
||||||
@PutMapping("/sections/{id}")
|
@PutMapping("/sections/{id}")
|
||||||
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
fun renameSection(
|
||||||
contentService.renameSection(id, body.title)
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: TitlePayload,
|
||||||
|
) = authoring.renameSection(id, body.title)
|
||||||
|
|
||||||
@DeleteMapping("/sections/{id}")
|
@DeleteMapping("/sections/{id}")
|
||||||
fun deleteSection(
|
fun deleteSection(
|
||||||
@@ -131,35 +160,46 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
@RequestParam(required = false) moveTo: UUID?,
|
@RequestParam(required = false) moveTo: UUID?,
|
||||||
): ResponseEntity<Void> {
|
): ResponseEntity<Void> {
|
||||||
if (moveTo != null) {
|
if (moveTo != null) {
|
||||||
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
authoring.moveArticlesAndDeleteSection(id, moveTo)
|
||||||
} else {
|
} else {
|
||||||
contentService.deleteSection(id)
|
authoring.deleteSection(id)
|
||||||
}
|
}
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/articles")
|
@PostMapping("/articles")
|
||||||
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
|
fun createArticle(
|
||||||
|
@RequestBody body: ArticleCreatePayload,
|
||||||
|
) = authoring.createArticlePlaced(
|
||||||
|
ArticlePlacement(
|
||||||
title = body.title,
|
title = body.title,
|
||||||
bodyMd = body.bodyMd,
|
bodyMd = body.bodyMd,
|
||||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||||
newChapterTitle = body.newChapterTitle,
|
newChapterTitle = body.newChapterTitle,
|
||||||
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
||||||
newSectionTitle = body.newSectionTitle,
|
newSectionTitle = body.newSectionTitle,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@PutMapping("/articles/{id}")
|
@PutMapping("/articles/{id}")
|
||||||
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
|
fun update(
|
||||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: ArticleUpdatePayload,
|
||||||
|
) {
|
||||||
|
authoring.updateArticle(id, body.title, body.bodyMd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/articles/{id}/move")
|
@PostMapping("/articles/{id}/move")
|
||||||
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
|
fun move(
|
||||||
contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: ArticleMovePayload,
|
||||||
|
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
|
||||||
|
|
||||||
@DeleteMapping("/articles/{id}")
|
@DeleteMapping("/articles/{id}")
|
||||||
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
|
fun delete(
|
||||||
contentService.deleteArticle(id)
|
@PathVariable id: UUID,
|
||||||
|
): ResponseEntity<Void> {
|
||||||
|
authoring.deleteArticle(id)
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 org.springframework.web.bind.annotation.RestController
|
||||||
import ru.ysu.lms.academic.content.AssetStorage
|
import ru.ysu.lms.academic.content.AssetStorage
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
import java.nio.file.Files
|
|
||||||
|
|
||||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||||
@RestController
|
@RestController
|
||||||
@@ -18,7 +17,6 @@ class ContentPublicController(
|
|||||||
private val contentService: ContentService,
|
private val contentService: ContentService,
|
||||||
private val assetStorage: AssetStorage,
|
private val assetStorage: AssetStorage,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping("/tree")
|
@GetMapping("/tree")
|
||||||
fun tree() = contentService.tree()
|
fun tree() = contentService.tree()
|
||||||
|
|
||||||
@@ -30,9 +28,15 @@ class ContentPublicController(
|
|||||||
): ContentArticleViewDto {
|
): ContentArticleViewDto {
|
||||||
val a = contentService.findArticle(chapter, section, article)
|
val a = contentService.findArticle(chapter, section, article)
|
||||||
return ContentArticleViewDto(
|
return ContentArticleViewDto(
|
||||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
id = a.id,
|
||||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
chapterSlug = a.chapterSlug,
|
||||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
chapterTitle = a.chapterTitle,
|
||||||
|
sectionSlug = a.sectionSlug,
|
||||||
|
sectionTitle = a.sectionTitle,
|
||||||
|
slug = a.slug,
|
||||||
|
title = a.title,
|
||||||
|
bodyMd = a.bodyMd,
|
||||||
|
published = a.published,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +53,19 @@ class ContentPublicController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/assets/{name}")
|
@GetMapping("/assets/{name}")
|
||||||
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
|
fun asset(
|
||||||
val (path, mime) = assetStorage.read(name)
|
@PathVariable name: String,
|
||||||
|
): ResponseEntity<FileSystemResource> {
|
||||||
|
val (path, mime) =
|
||||||
|
assetStorage.read(name)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
.contentType(media)
|
.contentType(media)
|
||||||
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
|
.cacheControl(
|
||||||
.body(FileSystemResource(path))
|
org.springframework.http.CacheControl
|
||||||
|
.maxAge(java.time.Duration.ofDays(7)),
|
||||||
|
).body(FileSystemResource(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,72 @@
|
|||||||
package ru.ysu.lms.academic.controller
|
package ru.ysu.lms.academic.controller
|
||||||
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.*
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import ru.ysu.lms.academic.lab.*
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import ru.ysu.lms.academic.lab.LabCreateRequest
|
||||||
|
import ru.ysu.lms.academic.lab.LabDetail
|
||||||
|
import ru.ysu.lms.academic.lab.LabImportResult
|
||||||
|
import ru.ysu.lms.academic.lab.LabService
|
||||||
|
import ru.ysu.lms.academic.lab.LabSummary
|
||||||
|
import ru.ysu.lms.academic.lab.LabUpdateRequest
|
||||||
|
import ru.ysu.lms.academic.lab.LevelRenameRequest
|
||||||
|
import ru.ysu.lms.academic.lab.LevelSaveRequest
|
||||||
|
import ru.ysu.lms.academic.lab.VariantSaveRequest
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/labs")
|
@RequestMapping("/api/v1/labs")
|
||||||
class LabController(private val labService: LabService) {
|
class LabController(
|
||||||
|
private val labService: LabService,
|
||||||
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(): List<LabSummary> = labService.list()
|
fun list(): List<LabSummary> = labService.list()
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
|
fun detail(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): LabDetail = labService.detail(id)
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
|
fun importLab(
|
||||||
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
): LabImportResult {
|
||||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
|
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun delete(@PathVariable id: UUID) {
|
fun delete(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
) {
|
||||||
labService.delete(id)
|
labService.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
|
fun update(
|
||||||
labService.update(id, body)
|
@PathVariable id: UUID,
|
||||||
|
@Valid @RequestBody body: LabUpdateRequest,
|
||||||
|
): LabDetail = labService.update(id, body)
|
||||||
|
|
||||||
@PostMapping("/{id}/variants")
|
@PostMapping("/{id}/variants")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
|
fun addVariant(
|
||||||
labService.addVariant(id, body)
|
@PathVariable id: UUID,
|
||||||
|
@Valid @RequestBody body: VariantSaveRequest,
|
||||||
|
): LabDetail = labService.addVariant(id, body)
|
||||||
|
|
||||||
@PutMapping("/{id}/variants/{variantId}")
|
@PutMapping("/{id}/variants/{variantId}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}/variants/{variantId}")
|
@DeleteMapping("/{id}/variants/{variantId}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
fun deleteVariant(
|
||||||
labService.deleteVariant(id, variantId)
|
@PathVariable id: UUID,
|
||||||
|
@PathVariable variantId: UUID,
|
||||||
|
): LabDetail = labService.deleteVariant(id, variantId)
|
||||||
|
|
||||||
@PostMapping("/{id}/toggle-status")
|
@PostMapping("/{id}/toggle-status")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun toggleStatus(@PathVariable id: UUID) {
|
fun toggleStatus(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
) {
|
||||||
labService.toggleStatus(id)
|
labService.toggleStatus(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
|
fun create(
|
||||||
|
@Valid @RequestBody body: LabCreateRequest,
|
||||||
|
): LabDetail = labService.create(body)
|
||||||
|
|
||||||
@PostMapping("/{id}/levels")
|
@PostMapping("/{id}/levels")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun addLevel(@PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest): LabDetail {
|
fun addLevel(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@Valid @RequestBody body: LevelSaveRequest,
|
||||||
|
): LabDetail {
|
||||||
labService.addLevel(id, body)
|
labService.addLevel(id, body)
|
||||||
return labService.detail(id)
|
return labService.detail(id)
|
||||||
}
|
}
|
||||||
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}/levels/{levelId}")
|
@DeleteMapping("/{id}/levels/{levelId}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
|
fun deleteLevel(
|
||||||
labService.deleteLevel(id, levelId)
|
@PathVariable id: UUID,
|
||||||
|
@PathVariable levelId: UUID,
|
||||||
|
): LabDetail = labService.deleteLevel(id, levelId)
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
|
|||||||
import jakarta.persistence.Id
|
import jakarta.persistence.Id
|
||||||
import jakarta.persistence.JoinColumn
|
import jakarta.persistence.JoinColumn
|
||||||
import jakarta.persistence.ManyToOne
|
import jakarta.persistence.ManyToOne
|
||||||
import jakarta.persistence.OneToMany
|
|
||||||
import jakarta.persistence.OrderBy
|
|
||||||
import jakarta.persistence.Table
|
import jakarta.persistence.Table
|
||||||
import ru.ysu.lms.common.Ids
|
import ru.ysu.lms.common.Ids
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class LabImportParser {
|
class LabImportParser {
|
||||||
private val yaml = ObjectMapper(YAMLFactory())
|
private val yaml =
|
||||||
|
ObjectMapper(YAMLFactory())
|
||||||
.registerModule(KotlinModule.Builder().build())
|
.registerModule(KotlinModule.Builder().build())
|
||||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
|
fun parse(
|
||||||
|
filename: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): LabImportBundle {
|
||||||
if (!filename.lowercase().endsWith(".zip")) {
|
if (!filename.lowercase().endsWith(".zip")) {
|
||||||
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
|
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
|
||||||
}
|
}
|
||||||
@@ -29,89 +33,115 @@ class LabImportParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseZip(bytes: ByteArray): LabImportBundle {
|
private fun parseZip(bytes: ByteArray): LabImportBundle {
|
||||||
|
val entries = readEntries(bytes)
|
||||||
|
val meta = readMeta(entries)
|
||||||
|
val readmeMd = readReadme(entries)
|
||||||
|
|
||||||
|
val variants = mutableListOf<VariantMeta>()
|
||||||
|
val assets = mutableMapOf<String, ByteArray>()
|
||||||
|
for ((path, data) in entries) {
|
||||||
|
val normalized = path.replace('\\', '/')
|
||||||
|
when {
|
||||||
|
isInDir(normalized, "variants") ->
|
||||||
|
parseVariantFile(normalized, String(data, Charsets.UTF_8), meta.levels)?.let(variants::add)
|
||||||
|
isInDir(normalized, "assets") -> assets[normalized] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (variants.isEmpty()) {
|
||||||
|
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
|
||||||
|
}
|
||||||
|
return LabImportBundle(meta, readmeMd, variants, assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
|
||||||
val entries = mutableMapOf<String, ByteArray>()
|
val entries = mutableMapOf<String, ByteArray>()
|
||||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||||
var entry = zis.nextEntry
|
var entry = zis.nextEntry
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
if (!entry.isDirectory) {
|
if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
|
||||||
entries[entry.name] = zis.readAllBytes()
|
|
||||||
}
|
|
||||||
entry = zis.nextEntry
|
entry = zis.nextEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
|
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")
|
?: throw BadRequestException("В архиве не найден lab.yaml")
|
||||||
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
|
return 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()) {
|
private fun readReadme(entries: Map<String, ByteArray>): String =
|
||||||
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
|
entries.entries
|
||||||
}
|
.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
|
||||||
|
?.let { String(it.value, Charsets.UTF_8) }
|
||||||
|
?: ""
|
||||||
|
|
||||||
return LabImportBundle(meta, readmeMd, variants, assets)
|
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? {
|
private fun parseVariantFile(
|
||||||
val levelMap = levels.associateBy { it.code }
|
path: String,
|
||||||
|
content: String,
|
||||||
|
levels: List<LabLevelMeta>,
|
||||||
|
): VariantMeta? {
|
||||||
val lines = content.lines()
|
val lines = content.lines()
|
||||||
val firstSep = lines.indexOfFirst { it.trim() == "---" }
|
val frontMatter =
|
||||||
if (firstSep != 0) {
|
readFrontMatter(lines)
|
||||||
return VariantMeta(
|
?: return VariantMeta(
|
||||||
level = "basic",
|
level = "basic",
|
||||||
number = path.substringAfterLast("/").removeSuffix(".md"),
|
number = path.substringAfterLast("/").removeSuffix(".md"),
|
||||||
title = "",
|
title = "",
|
||||||
statement = content.trim(),
|
statement = content.trim(),
|
||||||
)
|
)
|
||||||
}
|
val levelCode = normalizeLevel(frontMatter.values["level"] ?: "basic")
|
||||||
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
|
val number = frontMatter.values["number"]?.takeIf { it.isNotBlank() }
|
||||||
if (secondSep < 0) return null
|
if (levels.none { it.code == levelCode } || number == null) return null
|
||||||
val fmLines = lines.subList(1, secondSep)
|
val body = lines.drop(frontMatter.bodyStart).joinToString("\n").trim()
|
||||||
val fm = mutableMapOf<String, String>()
|
|
||||||
for (line in fmLines) {
|
|
||||||
val parts = line.split(":", limit = 2)
|
|
||||||
if (parts.size == 2) fm[parts[0].trim()] = unquote(parts[1].trim())
|
|
||||||
}
|
|
||||||
val levelCode = normalizeLevel(fm["level"] ?: "basic")
|
|
||||||
if (levelMap[levelCode] == null) return null
|
|
||||||
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
|
|
||||||
return VariantMeta(
|
return VariantMeta(
|
||||||
level = levelCode,
|
level = levelCode,
|
||||||
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null,
|
number = number,
|
||||||
title = fm["title"] ?: "",
|
title = frontMatter.values["title"] ?: "",
|
||||||
statement = body.ifBlank { "—" },
|
statement = body.ifBlank { "—" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unquote(value: String): String =
|
private data class FrontMatter(
|
||||||
if (value.length >= 2 &&
|
val values: Map<String, String>,
|
||||||
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\''))
|
val bodyStart: Int,
|
||||||
) {
|
)
|
||||||
value.substring(1, value.length - 1)
|
|
||||||
} else {
|
/** Читает YAML-front-matter (между двумя `---`), иначе возвращает null. */
|
||||||
value
|
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 normalizeLevel(s: String): String = when (s.lowercase().trim()) {
|
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"
|
"main", "basic", "обычная", "основная" -> "basic"
|
||||||
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
||||||
else -> s.lowercase().trim()
|
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.LabRepository
|
||||||
import ru.ysu.lms.academic.repo.SemesterRepository
|
import ru.ysu.lms.academic.repo.SemesterRepository
|
||||||
import ru.ysu.lms.academic.repo.VariantRepository
|
import ru.ysu.lms.academic.repo.VariantRepository
|
||||||
import ru.ysu.lms.common.NotFoundException
|
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import ru.ysu.lms.common.ConflictException
|
import ru.ysu.lms.common.ConflictException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -28,11 +28,14 @@ class LabService(
|
|||||||
private val semesters: SemesterRepository,
|
private val semesters: SemesterRepository,
|
||||||
private val parser: LabImportParser,
|
private val parser: LabImportParser,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
|
fun importFromFile(
|
||||||
|
filename: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): LabImportResult {
|
||||||
val bundle = parser.parse(filename, bytes)
|
val bundle = parser.parse(filename, bytes)
|
||||||
val semester = semesters.findFirstByActiveTrue()
|
val semester =
|
||||||
|
semesters.findFirstByActiveTrue()
|
||||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||||
|
|
||||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||||
@@ -41,7 +44,9 @@ class LabService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val lab = labs.save(Lab(
|
val lab =
|
||||||
|
labs.save(
|
||||||
|
Lab(
|
||||||
number = bundle.meta.number,
|
number = bundle.meta.number,
|
||||||
title = bundle.meta.title,
|
title = bundle.meta.title,
|
||||||
semester = semester,
|
semester = semester,
|
||||||
@@ -50,38 +55,33 @@ class LabService(
|
|||||||
status = bundle.meta.status,
|
status = bundle.meta.status,
|
||||||
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
||||||
defenseAt = parseInstant(bundle.meta.defenseAt),
|
defenseAt = parseInstant(bundle.meta.defenseAt),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val levelMetas = bundle.meta.levels.ifEmpty {
|
val levelMetas =
|
||||||
|
bundle.meta.levels.ifEmpty {
|
||||||
listOf(
|
listOf(
|
||||||
LabLevelMeta("basic", "Основная группа", position = 1),
|
LabLevelMeta("basic", "Основная группа", position = 1),
|
||||||
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val levelEntities = levelMetas.map { meta ->
|
val levelEntities =
|
||||||
labLevels.save(LabLevel(
|
levelMetas.map { meta ->
|
||||||
|
labLevels.save(
|
||||||
|
LabLevel(
|
||||||
lab = lab,
|
lab = lab,
|
||||||
code = meta.code,
|
code = meta.code,
|
||||||
title = meta.title,
|
title = meta.title,
|
||||||
description = meta.description.orEmpty(),
|
description = meta.description.orEmpty(),
|
||||||
position = meta.position ?: 0,
|
position = meta.position ?: 0,
|
||||||
pointsMax = meta.pointsMax,
|
pointsMax = meta.pointsMax,
|
||||||
))
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val levelByCode = levelEntities.associateBy { it.code }
|
val levelByCode = levelEntities.associateBy { it.code }
|
||||||
|
|
||||||
bundle.variants.forEach { vmeta ->
|
saveVariants(lab, levelByCode, bundle.variants)
|
||||||
val level = levelByCode[vmeta.level]
|
|
||||||
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
|
|
||||||
variantsRepo.save(Variant(
|
|
||||||
lab = lab,
|
|
||||||
labLevel = level,
|
|
||||||
number = vmeta.number,
|
|
||||||
title = vmeta.title,
|
|
||||||
statementMd = vmeta.statement,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return LabImportResult(
|
return LabImportResult(
|
||||||
labId = lab.id.toString(),
|
labId = lab.id.toString(),
|
||||||
@@ -122,16 +122,20 @@ class LabService(
|
|||||||
deadlineAt = lab.deadlineAt?.toString(),
|
deadlineAt = lab.deadlineAt?.toString(),
|
||||||
defenseAt = lab.defenseAt?.toString(),
|
defenseAt = lab.defenseAt?.toString(),
|
||||||
pointsMax = lab.pointsMax,
|
pointsMax = lab.pointsMax,
|
||||||
levels = levels.map { level ->
|
levels =
|
||||||
|
levels.map { level ->
|
||||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||||
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
val vs =
|
||||||
|
variantsRepo
|
||||||
|
.findByLabIdOrderByNumber(lab.id)
|
||||||
.filter { it.labLevel?.id == level.id }
|
.filter { it.labLevel?.id == level.id }
|
||||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||||
LabLevelDetail(
|
LabLevelDetail(
|
||||||
id = level.id.toString(),
|
id = level.id.toString(),
|
||||||
code = level.code,
|
code = level.code,
|
||||||
title = level.title,
|
title = level.title,
|
||||||
variants = vs.map { v ->
|
variants =
|
||||||
|
vs.map { v ->
|
||||||
VariantDetail(
|
VariantDetail(
|
||||||
id = v.id.toString(),
|
id = v.id.toString(),
|
||||||
number = v.number,
|
number = v.number,
|
||||||
@@ -153,7 +157,10 @@ class LabService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
|
fun update(
|
||||||
|
labId: UUID,
|
||||||
|
request: LabUpdateRequest,
|
||||||
|
): LabDetail {
|
||||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||||
lab.title = request.title.trim()
|
lab.title = request.title.trim()
|
||||||
@@ -167,8 +174,14 @@ class LabService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
|
fun updateVariant(
|
||||||
val variant = variantsRepo.findById(variantId)
|
labId: UUID,
|
||||||
|
variantId: UUID,
|
||||||
|
request: VariantSaveRequest,
|
||||||
|
): LabDetail {
|
||||||
|
val variant =
|
||||||
|
variantsRepo
|
||||||
|
.findById(variantId)
|
||||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||||
@@ -176,7 +189,9 @@ class LabService(
|
|||||||
variant.title = request.title.orEmpty().trim()
|
variant.title = request.title.orEmpty().trim()
|
||||||
variant.statementMd = request.statementMd.orEmpty()
|
variant.statementMd = request.statementMd.orEmpty()
|
||||||
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
|
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
|
||||||
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
|
variant.labLevel =
|
||||||
|
labLevels
|
||||||
|
.findById(UUID.fromString(levelId))
|
||||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||||
}
|
}
|
||||||
variantsRepo.save(variant)
|
variantsRepo.save(variant)
|
||||||
@@ -184,53 +199,52 @@ class LabService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
|
fun addVariant(
|
||||||
|
labId: UUID,
|
||||||
|
request: VariantSaveRequest,
|
||||||
|
): LabDetail {
|
||||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||||
val level = labLevels.findById(UUID.fromString(request.labLevelId))
|
val level =
|
||||||
|
labLevels
|
||||||
|
.findById(UUID.fromString(request.labLevelId))
|
||||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||||
variantsRepo.save(Variant(
|
variantsRepo.save(
|
||||||
|
Variant(
|
||||||
lab = lab,
|
lab = lab,
|
||||||
labLevel = level,
|
labLevel = level,
|
||||||
number = request.number.trim(),
|
number = request.number.trim(),
|
||||||
title = request.title.orEmpty().trim(),
|
title = request.title.orEmpty().trim(),
|
||||||
statementMd = request.statementMd.orEmpty(),
|
statementMd = request.statementMd.orEmpty(),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
return detail(labId)
|
return detail(labId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
|
fun deleteVariant(
|
||||||
val variant = variantsRepo.findById(variantId)
|
labId: UUID,
|
||||||
|
variantId: UUID,
|
||||||
|
): LabDetail {
|
||||||
|
val variant =
|
||||||
|
variantsRepo
|
||||||
|
.findById(variantId)
|
||||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||||
variantsRepo.delete(variant)
|
variantsRepo.delete(variant)
|
||||||
return detail(labId)
|
return detail(labId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
private fun validateCreate(request: LabCreateRequest) {
|
||||||
fun create(request: LabCreateRequest): LabDetail {
|
|
||||||
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
|
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
|
||||||
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
|
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
|
||||||
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
|
|
||||||
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
|
||||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
|
||||||
}
|
|
||||||
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 ->
|
levelMetas.forEachIndexed { index, meta ->
|
||||||
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||||
labLevels.save(
|
labLevels.save(
|
||||||
@@ -243,11 +257,59 @@ class LabService(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveVariants(
|
||||||
|
lab: Lab,
|
||||||
|
levelByCode: Map<String, LabLevel>,
|
||||||
|
variantMetas: List<VariantMeta>,
|
||||||
|
) {
|
||||||
|
variantMetas.forEach { vmeta ->
|
||||||
|
val level =
|
||||||
|
levelByCode[vmeta.level]
|
||||||
|
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
|
||||||
|
variantsRepo.save(
|
||||||
|
Variant(
|
||||||
|
lab = lab,
|
||||||
|
labLevel = level,
|
||||||
|
number = vmeta.number,
|
||||||
|
title = vmeta.title,
|
||||||
|
statementMd = vmeta.statement,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(request: LabCreateRequest): LabDetail {
|
||||||
|
validateCreate(request)
|
||||||
|
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
|
||||||
|
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
||||||
|
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
||||||
|
}
|
||||||
|
val lab =
|
||||||
|
labs.save(
|
||||||
|
Lab(
|
||||||
|
number = request.number ?: 0,
|
||||||
|
title = request.title.orEmpty().trim(),
|
||||||
|
semester = semester,
|
||||||
|
descriptionMd = request.descriptionMd.orEmpty(),
|
||||||
|
deadlineAt = parseInstant(request.deadlineAt),
|
||||||
|
defenseAt = parseInstant(request.defenseAt),
|
||||||
|
pointsMax = request.pointsMax ?: BigDecimal(5),
|
||||||
|
status = request.status ?: "draft",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
saveLevels(lab, request.levels)
|
||||||
return detail(lab.id)
|
return detail(lab.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail {
|
fun renameLevel(
|
||||||
|
labId: UUID,
|
||||||
|
levelId: UUID,
|
||||||
|
request: LevelRenameRequest,
|
||||||
|
): LabDetail {
|
||||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||||
level.title = request.title.ifBlank { level.title }
|
level.title = request.title.ifBlank { level.title }
|
||||||
@@ -257,7 +319,10 @@ class LabService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
|
fun deleteLevel(
|
||||||
|
labId: UUID,
|
||||||
|
levelId: UUID,
|
||||||
|
): LabDetail {
|
||||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||||
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
|
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
|
||||||
@@ -268,7 +333,10 @@ class LabService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
fun addLevel(
|
||||||
|
labId: UUID,
|
||||||
|
request: LevelSaveRequest,
|
||||||
|
) {
|
||||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||||
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
||||||
@@ -290,15 +358,24 @@ class LabService(
|
|||||||
labs.save(lab)
|
labs.save(lab)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
private fun findOrCreateSemester(
|
||||||
|
title: String,
|
||||||
|
year: Int,
|
||||||
|
): Semester {
|
||||||
semesters.findFirstByActiveTrue()?.let { return it }
|
semesters.findFirstByActiveTrue()?.let { return it }
|
||||||
val t = title.ifBlank { "осень" }
|
val t = title.ifBlank { "осень" }
|
||||||
val y = if (year > 0) year else java.time.LocalDate.now().year
|
val y =
|
||||||
|
if (year > 0) {
|
||||||
|
year
|
||||||
|
} else {
|
||||||
|
java.time.LocalDate
|
||||||
|
.now()
|
||||||
|
.year
|
||||||
|
}
|
||||||
return semesters.save(Semester(title = t, year = y, active = true))
|
return semesters.save(Semester(title = t, year = y, active = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun naturalNumber(value: String): Int =
|
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||||
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
|
||||||
|
|
||||||
private fun parseInstant(value: String?): Instant? {
|
private fun parseInstant(value: String?): Instant? {
|
||||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
|||||||
@@ -8,20 +8,40 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
||||||
|
|
||||||
fun findBySlug(slug: String): ContentChapter?
|
fun findBySlug(slug: String): ContentChapter?
|
||||||
|
|
||||||
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
||||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
|
||||||
|
|
||||||
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
|
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
|
||||||
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
|
|
||||||
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection?
|
fun findByChapterIdAndSlug(
|
||||||
|
chapterId: UUID,
|
||||||
|
slug: String,
|
||||||
|
): ContentSection?
|
||||||
|
|
||||||
|
fun findByChapterIdAndTitleIgnoreCase(
|
||||||
|
chapterId: UUID,
|
||||||
|
title: String,
|
||||||
|
): ContentSection?
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
||||||
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
|
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
|
||||||
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
|
|
||||||
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle?
|
fun findBySectionIdAndSlug(
|
||||||
|
sectionId: UUID,
|
||||||
|
slug: String,
|
||||||
|
): ContentArticle?
|
||||||
|
|
||||||
|
fun findBySectionIdAndTitleIgnoreCase(
|
||||||
|
sectionId: UUID,
|
||||||
|
title: String,
|
||||||
|
): ContentArticle?
|
||||||
|
|
||||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,9 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||||
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
|
|
||||||
|
fun existsByNumberAndSemesterId(
|
||||||
|
number: Int,
|
||||||
|
semesterId: UUID,
|
||||||
|
): Boolean
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
|||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.core.GrantedAuthority
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||||
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
class SecurityConfig {
|
class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtDecoder(): JwtDecoder =
|
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||||
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
|
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
|
||||||
val converter = JwtAuthenticationConverter()
|
val converter = JwtAuthenticationConverter()
|
||||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||||
val role = jwt.getClaimAsString("role")
|
val role = jwt.getClaimAsString("role")
|
||||||
if (role.isNullOrBlank()) emptyList()
|
if (role.isNullOrBlank()) {
|
||||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return converter
|
return converter
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,8 @@ class SecurityConfig {
|
|||||||
.cors { it.disable() }
|
.cors { it.disable() }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
auth.requestMatchers(
|
auth
|
||||||
|
.requestMatchers(
|
||||||
"/actuator/**",
|
"/actuator/**",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/swagger-ui/**",
|
"/swagger-ui/**",
|
||||||
@@ -52,8 +53,7 @@ class SecurityConfig {
|
|||||||
"/api/v1/content/{chapter}/{section}/{article}",
|
"/api/v1/content/{chapter}/{section}/{article}",
|
||||||
).permitAll()
|
).permitAll()
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}.oauth2ResourceServer { oauth ->
|
||||||
.oauth2ResourceServer { oauth ->
|
|
||||||
oauth.jwt { jwt ->
|
oauth.jwt { jwt ->
|
||||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +16,26 @@ import reactor.core.publisher.Mono
|
|||||||
* identity to the downstream services as `X-Account-*` headers.
|
* identity to the downstream services as `X-Account-*` headers.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
|
class JwtAuthenticationFilter(
|
||||||
|
private val jwtDecoder: JwtDecoder,
|
||||||
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
) : GlobalFilter,
|
||||||
|
Ordered {
|
||||||
|
override fun filter(
|
||||||
|
exchange: ServerWebExchange,
|
||||||
|
chain: GatewayFilterChain,
|
||||||
|
): Mono<Void> {
|
||||||
val path = exchange.request.path.value()
|
val path = exchange.request.path.value()
|
||||||
if (isPublic(path)) {
|
if (isPublic(path)) {
|
||||||
return chain.filter(exchange)
|
return chain.filter(exchange)
|
||||||
}
|
}
|
||||||
val token = bearerToken(exchange)
|
val token =
|
||||||
|
bearerToken(exchange)
|
||||||
?: return unauthorized(exchange, "Требуется вход")
|
?: return unauthorized(exchange, "Требуется вход")
|
||||||
return try {
|
return try {
|
||||||
val jwt = jwtDecoder.decode(token)
|
val jwt = jwtDecoder.decode(token)
|
||||||
val request = exchange.request.mutate()
|
val request =
|
||||||
|
exchange.request
|
||||||
|
.mutate()
|
||||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||||
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
||||||
@@ -56,7 +64,10 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
|
|||||||
return header.removePrefix("Bearer ").trim().ifBlank { null }
|
return header.removePrefix("Bearer ").trim().ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
|
private fun unauthorized(
|
||||||
|
exchange: ServerWebExchange,
|
||||||
|
message: String,
|
||||||
|
): Mono<Void> {
|
||||||
val response = exchange.response
|
val response = exchange.response
|
||||||
response.statusCode = HttpStatus.UNAUTHORIZED
|
response.statusCode = HttpStatus.UNAUTHORIZED
|
||||||
response.headers.contentType = MediaType.APPLICATION_JSON
|
response.headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
|||||||
@@ -18,23 +18,28 @@ class AuthController(
|
|||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
private val rsaKey: RSAKey,
|
private val rsaKey: RSAKey,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
fun health(): Map<String, Any> = mapOf(
|
fun health(): Map<String, Any> =
|
||||||
|
mapOf(
|
||||||
"ok" to true,
|
"ok" to true,
|
||||||
"service" to "identity-service",
|
"service" to "identity-service",
|
||||||
"adminExists" to authService.adminExists(),
|
"adminExists" to authService.adminExists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("/setup")
|
@PostMapping("/setup")
|
||||||
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
|
fun setup(
|
||||||
|
@Valid @RequestBody body: SetupRequest,
|
||||||
|
): AuthResponse = authService.setup(body)
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
|
fun login(
|
||||||
|
@Valid @RequestBody body: LoginRequest,
|
||||||
|
): AuthResponse = authService.login(body)
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
|
fun me(
|
||||||
authService.me(UUID.fromString(jwt.subject))
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): MeResponse = authService.me(UUID.fromString(jwt.subject))
|
||||||
|
|
||||||
@PostMapping("/change-password")
|
@PostMapping("/change-password")
|
||||||
fun changePassword(
|
fun changePassword(
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class AuthService(
|
|||||||
private val jwtEncoder: JwtEncoder,
|
private val jwtEncoder: JwtEncoder,
|
||||||
private val jwtProperties: JwtProperties,
|
private val jwtProperties: JwtProperties,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -41,7 +40,8 @@ class AuthService(
|
|||||||
throw BadRequestException("Логин: минимум 3 символа")
|
throw BadRequestException("Логин: минимум 3 символа")
|
||||||
}
|
}
|
||||||
validatePassword(request.password, request.password2)
|
validatePassword(request.password, request.password2)
|
||||||
val account = Account(
|
val account =
|
||||||
|
Account(
|
||||||
login = login,
|
login = login,
|
||||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||||
role = LmsRole.ADMIN,
|
role = LmsRole.ADMIN,
|
||||||
@@ -53,7 +53,8 @@ class AuthService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun login(request: LoginRequest): AuthResponse {
|
fun login(request: LoginRequest): AuthResponse {
|
||||||
val account = accounts.findByLogin(request.login.trim())
|
val account =
|
||||||
|
accounts.findByLogin(request.login.trim())
|
||||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||||
throw UnauthorizedException("Неверный логин или пароль")
|
throw UnauthorizedException("Неверный логин или пароль")
|
||||||
@@ -63,7 +64,9 @@ class AuthService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun me(accountId: UUID): MeResponse {
|
fun me(accountId: UUID): MeResponse {
|
||||||
val account = accounts.findById(accountId)
|
val account =
|
||||||
|
accounts
|
||||||
|
.findById(accountId)
|
||||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||||
return MeResponse(
|
return MeResponse(
|
||||||
id = account.id.toString(),
|
id = account.id.toString(),
|
||||||
@@ -75,8 +78,13 @@ class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
|
fun changePassword(
|
||||||
val account = accounts.findById(accountId)
|
accountId: UUID,
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
) {
|
||||||
|
val account =
|
||||||
|
accounts
|
||||||
|
.findById(accountId)
|
||||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||||
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
||||||
throw UnauthorizedException("Текущий пароль неверный")
|
throw UnauthorizedException("Текущий пароль неверный")
|
||||||
@@ -89,7 +97,10 @@ class AuthService(
|
|||||||
accounts.save(account)
|
accounts.save(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validatePassword(password: String, confirmation: String) {
|
private fun validatePassword(
|
||||||
|
password: String,
|
||||||
|
confirmation: String,
|
||||||
|
) {
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
throw BadRequestException("Пароль: минимум 8 символов")
|
throw BadRequestException("Пароль: минимум 8 символов")
|
||||||
}
|
}
|
||||||
@@ -100,14 +111,19 @@ class AuthService(
|
|||||||
|
|
||||||
private fun displayName(account: Account): String =
|
private fun displayName(account: Account): String =
|
||||||
if (account.role == LmsRole.STUDENT) {
|
if (account.role == LmsRole.STUDENT) {
|
||||||
students.findById(account.id)
|
students
|
||||||
|
.findById(account.id)
|
||||||
.map { it.fullName.ifBlank { account.login } }
|
.map { it.fullName.ifBlank { account.login } }
|
||||||
.orElse(account.login)
|
.orElse(account.login)
|
||||||
} else {
|
} else {
|
||||||
account.login
|
account.login
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
|
private fun toResponse(
|
||||||
|
account: Account,
|
||||||
|
name: String,
|
||||||
|
): AuthResponse =
|
||||||
|
AuthResponse(
|
||||||
token = issueToken(account, name),
|
token = issueToken(account, name),
|
||||||
id = account.id.toString(),
|
id = account.id.toString(),
|
||||||
login = account.login,
|
login = account.login,
|
||||||
@@ -116,9 +132,14 @@ class AuthService(
|
|||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun issueToken(account: Account, name: String): String {
|
private fun issueToken(
|
||||||
|
account: Account,
|
||||||
|
name: String,
|
||||||
|
): String {
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
val claims = JwtClaimsSet.builder()
|
val claims =
|
||||||
|
JwtClaimsSet
|
||||||
|
.builder()
|
||||||
.issuer(jwtProperties.issuer)
|
.issuer(jwtProperties.issuer)
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||||
|
|||||||
@@ -16,26 +16,19 @@ import java.util.UUID
|
|||||||
class Account(
|
class Account(
|
||||||
@Id
|
@Id
|
||||||
var id: UUID = Ids.v7(),
|
var id: UUID = Ids.v7(),
|
||||||
|
|
||||||
@Column(nullable = false, unique = true, length = 64)
|
@Column(nullable = false, unique = true, length = 64)
|
||||||
var login: String = "",
|
var login: String = "",
|
||||||
|
|
||||||
@Column(name = "password_hash", nullable = false)
|
@Column(name = "password_hash", nullable = false)
|
||||||
var passwordHash: String = "",
|
var passwordHash: String = "",
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 16)
|
@Column(nullable = false, length = 16)
|
||||||
var role: LmsRole = LmsRole.STUDENT,
|
var role: LmsRole = LmsRole.STUDENT,
|
||||||
|
|
||||||
@Column(name = "must_change_password", nullable = false)
|
@Column(name = "must_change_password", nullable = false)
|
||||||
var mustChangePassword: Boolean = true,
|
var mustChangePassword: Boolean = true,
|
||||||
|
|
||||||
@Column(name = "temp_password", length = 64)
|
@Column(name = "temp_password", length = 64)
|
||||||
var tempPassword: String? = null,
|
var tempPassword: String? = null,
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
var createdAt: Instant = Instant.now(),
|
var createdAt: Instant = Instant.now(),
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
var updatedAt: Instant = Instant.now(),
|
var updatedAt: Instant = Instant.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,15 +16,12 @@ import java.util.UUID
|
|||||||
class Enrollment(
|
class Enrollment(
|
||||||
@Id
|
@Id
|
||||||
var id: UUID = Ids.v7(),
|
var id: UUID = Ids.v7(),
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@JoinColumn(name = "group_id", nullable = false)
|
@JoinColumn(name = "group_id", nullable = false)
|
||||||
var group: GroupEntity? = null,
|
var group: GroupEntity? = null,
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@JoinColumn(name = "student_id", nullable = false)
|
@JoinColumn(name = "student_id", nullable = false)
|
||||||
var student: Student? = null,
|
var student: Student? = null,
|
||||||
|
|
||||||
@Column(name = "enrolled_at", nullable = false)
|
@Column(name = "enrolled_at", nullable = false)
|
||||||
var enrolledAt: Instant = Instant.now(),
|
var enrolledAt: Instant = Instant.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,13 +13,10 @@ import java.util.UUID
|
|||||||
class GroupEntity(
|
class GroupEntity(
|
||||||
@Id
|
@Id
|
||||||
var id: UUID = Ids.v7(),
|
var id: UUID = Ids.v7(),
|
||||||
|
|
||||||
@Column(nullable = false, unique = true, length = 100)
|
@Column(nullable = false, unique = true, length = 100)
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
|
|
||||||
@Column(name = "task_duration_min", nullable = false)
|
@Column(name = "task_duration_min", nullable = false)
|
||||||
var taskDurationMin: Int = 30,
|
var taskDurationMin: Int = 30,
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
var createdAt: Instant = Instant.now(),
|
var createdAt: Instant = Instant.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class Setting(
|
|||||||
@Id
|
@Id
|
||||||
@Column(name = "key", length = 100)
|
@Column(name = "key", length = 100)
|
||||||
var key: String = "",
|
var key: String = "",
|
||||||
|
|
||||||
@Column(name = "value", nullable = false)
|
@Column(name = "value", nullable = false)
|
||||||
var value: String = "",
|
var value: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,32 +16,26 @@ class Student(
|
|||||||
@Id
|
@Id
|
||||||
@Column(name = "account_id")
|
@Column(name = "account_id")
|
||||||
var accountId: UUID? = null,
|
var accountId: UUID? = null,
|
||||||
|
|
||||||
@Column(name = "student_no", nullable = false, unique = true, length = 32)
|
@Column(name = "student_no", nullable = false, unique = true, length = 32)
|
||||||
var studentNo: String = "",
|
var studentNo: String = "",
|
||||||
|
|
||||||
@Column(name = "last_name", nullable = false, length = 100)
|
@Column(name = "last_name", nullable = false, length = 100)
|
||||||
var lastName: String = "",
|
var lastName: String = "",
|
||||||
|
|
||||||
@Column(name = "first_name", nullable = false, length = 100)
|
@Column(name = "first_name", nullable = false, length = 100)
|
||||||
var firstName: String = "",
|
var firstName: String = "",
|
||||||
|
|
||||||
@Column(name = "middle_name", nullable = false, length = 100)
|
@Column(name = "middle_name", nullable = false, length = 100)
|
||||||
var middleName: String = "",
|
var middleName: String = "",
|
||||||
|
|
||||||
@Column(name = "basis", nullable = false, length = 100)
|
@Column(name = "basis", nullable = false, length = 100)
|
||||||
var basis: String = "",
|
var basis: String = "",
|
||||||
|
|
||||||
@Column(name = "note", nullable = false)
|
@Column(name = "note", nullable = false)
|
||||||
var note: String = "",
|
var note: String = "",
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@MapsId
|
@MapsId
|
||||||
@JoinColumn(name = "account_id")
|
@JoinColumn(name = "account_id")
|
||||||
var account: Account? = null,
|
var account: Account? = null,
|
||||||
) {
|
) {
|
||||||
val fullName: String
|
val fullName: String
|
||||||
get() = listOf(lastName, firstName, middleName)
|
get() =
|
||||||
|
listOf(lastName, firstName, middleName)
|
||||||
.filter { it.isNotBlank() }
|
.filter { it.isNotBlank() }
|
||||||
.joinToString(" ")
|
.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface AccountRepository : JpaRepository<Account, UUID> {
|
interface AccountRepository : JpaRepository<Account, UUID> {
|
||||||
fun findByLogin(login: String): Account?
|
fun findByLogin(login: String): Account?
|
||||||
|
|
||||||
fun existsByLogin(login: String): Boolean
|
fun existsByLogin(login: String): Boolean
|
||||||
|
|
||||||
fun existsByRole(role: LmsRole): Boolean
|
fun existsByRole(role: LmsRole): Boolean
|
||||||
|
|
||||||
fun countByRole(role: LmsRole): Long
|
fun countByRole(role: LmsRole): Long
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
|
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
|
||||||
)
|
)
|
||||||
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
|
fun findAllByGroupId(
|
||||||
|
@Param("groupId") groupId: UUID,
|
||||||
|
): List<Enrollment>
|
||||||
|
|
||||||
fun findByStudent_AccountId(accountId: UUID): Enrollment?
|
fun findByStudentAccountId(accountId: UUID): Enrollment?
|
||||||
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
|
|
||||||
|
|
||||||
fun deleteByStudent_AccountId(accountId: UUID)
|
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
|
||||||
|
|
||||||
fun countByGroup_Id(groupId: UUID): Long
|
fun deleteByStudentAccountId(accountId: UUID)
|
||||||
|
|
||||||
|
fun countByGroupId(groupId: UUID): Long
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||||
fun findByName(name: String): GroupEntity?
|
fun findByName(name: String): GroupEntity?
|
||||||
|
|
||||||
fun existsByName(name: String): Boolean
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface StudentRepository : JpaRepository<Student, UUID> {
|
interface StudentRepository : JpaRepository<Student, UUID> {
|
||||||
fun findByStudentNo(studentNo: String): Student?
|
fun findByStudentNo(studentNo: String): Student?
|
||||||
|
|
||||||
fun existsByStudentNo(studentNo: String): Boolean
|
fun existsByStudentNo(studentNo: String): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
object CredentialsExporter {
|
object CredentialsExporter {
|
||||||
|
|
||||||
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
|
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
|
||||||
|
|
||||||
fun csv(rows: List<StudentRow>): ByteArray {
|
fun csv(rows: List<StudentRow>): ByteArray {
|
||||||
val builder = StringBuilder()
|
val builder = StringBuilder()
|
||||||
builder.append(HEADERS.joinToString(";")).append("\r\n")
|
builder.append(HEADERS.joinToString(";")).append("\r\n")
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
builder.append(
|
builder
|
||||||
|
.append(
|
||||||
listOf(
|
listOf(
|
||||||
row.fullName,
|
row.fullName,
|
||||||
row.studentNo,
|
row.studentNo,
|
||||||
@@ -41,8 +41,7 @@ object CredentialsExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun status(row: StudentRow): String =
|
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||||
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
|
||||||
|
|
||||||
private fun escapeCsv(value: String): String =
|
private fun escapeCsv(value: String): String =
|
||||||
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import org.springframework.http.HttpHeaders
|
|||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/groups")
|
@RequestMapping("/api/v1/groups")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class GroupController(private val groupService: GroupService) {
|
class GroupController(
|
||||||
|
private val groupService: GroupService,
|
||||||
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(): List<GroupSummary> = groupService.list()
|
fun list(): List<GroupSummary> = groupService.list()
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
|
fun create(
|
||||||
|
@Valid @RequestBody body: CreateGroupRequest,
|
||||||
|
): GroupSummary = groupService.create(body)
|
||||||
|
|
||||||
@PostMapping("/{id}/students")
|
@PostMapping("/{id}/students")
|
||||||
fun createStudent(
|
fun createStudent(
|
||||||
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
|
|||||||
|
|
||||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||||
@GetMapping("/{id}/candidates")
|
@GetMapping("/{id}/candidates")
|
||||||
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
|
fun candidates(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): List<StudentCandidate> = groupService.candidates(id)
|
||||||
|
|
||||||
/** Зачислить существующего студента. */
|
/** Зачислить существующего студента. */
|
||||||
@PostMapping("/{id}/students/{studentId}/enroll")
|
@PostMapping("/{id}/students/{studentId}/enroll")
|
||||||
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
fun enroll(
|
||||||
groupService.enroll(id, studentId)
|
@PathVariable id: UUID,
|
||||||
|
@PathVariable studentId: UUID,
|
||||||
|
): GroupDetail = groupService.enroll(id, studentId)
|
||||||
|
|
||||||
/** Отчислить студента из группы. */
|
/** Отчислить студента из группы. */
|
||||||
@DeleteMapping("/{id}/students/{studentId}")
|
@DeleteMapping("/{id}/students/{studentId}")
|
||||||
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
fun unenroll(
|
||||||
groupService.unenroll(id, studentId)
|
@PathVariable id: UUID,
|
||||||
|
@PathVariable studentId: UUID,
|
||||||
|
): GroupDetail = groupService.unenroll(id, studentId)
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
|
fun myGroup(
|
||||||
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
fun detail(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): GroupDetail = groupService.detail(id)
|
||||||
|
|
||||||
@GetMapping("/{id}/credentials")
|
@GetMapping("/{id}/credentials")
|
||||||
fun credentials(
|
fun credentials(
|
||||||
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
|
|||||||
): ResponseEntity<ByteArray> {
|
): ResponseEntity<ByteArray> {
|
||||||
val export = groupService.credentials(id, format)
|
val export = groupService.credentials(id, format)
|
||||||
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
|
||||||
.contentType(MediaType.parseMediaType(export.contentType))
|
.contentType(MediaType.parseMediaType(export.contentType))
|
||||||
.body(export.bytes)
|
.body(export.bytes)
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ class GroupService(
|
|||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val parser: StudentImportParser,
|
private val parser: StudentImportParser,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
|
fun createStudent(
|
||||||
|
groupId: UUID,
|
||||||
|
request: CreateStudentRequest,
|
||||||
|
): Credential {
|
||||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||||
val studentNo = request.studentNo.trim()
|
val studentNo = request.studentNo.trim()
|
||||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||||
@@ -40,7 +42,8 @@ class GroupService(
|
|||||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||||
}
|
}
|
||||||
val password = Passwords.generate()
|
val password = Passwords.generate()
|
||||||
val account = accounts.save(
|
val account =
|
||||||
|
accounts.save(
|
||||||
Account(
|
Account(
|
||||||
login = studentNo,
|
login = studentNo,
|
||||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||||
@@ -50,7 +53,8 @@ class GroupService(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||||
val student = students.save(
|
val student =
|
||||||
|
students.save(
|
||||||
Student(
|
Student(
|
||||||
studentNo = studentNo,
|
studentNo = studentNo,
|
||||||
lastName = request.lastName.trim(),
|
lastName = request.lastName.trim(),
|
||||||
@@ -85,13 +89,15 @@ class GroupService(
|
|||||||
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||||
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
|
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
|
||||||
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
|
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
|
||||||
for (e in enrollments.findAll()) {
|
enrollments
|
||||||
val sid = e.student?.accountId ?: continue
|
.findAll()
|
||||||
val gid = e.group?.id ?: continue
|
.mapNotNull { enrollment ->
|
||||||
val name = e.group?.name ?: continue
|
val sid = enrollment.student?.accountId ?: return@mapNotNull null
|
||||||
currentByStudent[sid] = gid to name
|
val group = enrollment.group ?: return@mapNotNull null
|
||||||
}
|
sid to (group.id to group.name)
|
||||||
return students.findAll()
|
}.forEach { (sid, group) -> currentByStudent[sid] = group }
|
||||||
|
return students
|
||||||
|
.findAll()
|
||||||
.filter { it.accountId !in memberIds }
|
.filter { it.accountId !in memberIds }
|
||||||
.map { s ->
|
.map { s ->
|
||||||
val current = s.accountId?.let { currentByStudent[it] }
|
val current = s.accountId?.let { currentByStudent[it] }
|
||||||
@@ -102,16 +108,18 @@ class GroupService(
|
|||||||
currentGroupId = current?.first?.toString(),
|
currentGroupId = current?.first?.toString(),
|
||||||
currentGroupName = current?.second,
|
currentGroupName = current?.second,
|
||||||
)
|
)
|
||||||
}
|
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||||
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
||||||
@Transactional
|
@Transactional
|
||||||
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
|
fun enroll(
|
||||||
|
groupId: UUID,
|
||||||
|
studentId: UUID,
|
||||||
|
): GroupDetail {
|
||||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||||
val existing = enrollments.findByStudent_AccountId(studentId)
|
val existing = enrollments.findByStudentAccountId(studentId)
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.group?.id == group.id) {
|
if (existing.group?.id == group.id) {
|
||||||
throw ConflictException("Студент уже в этой группе")
|
throw ConflictException("Студент уже в этой группе")
|
||||||
@@ -127,15 +135,23 @@ class GroupService(
|
|||||||
|
|
||||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||||
@Transactional
|
@Transactional
|
||||||
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
|
fun unenroll(
|
||||||
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
groupId: UUID,
|
||||||
|
studentId: UUID,
|
||||||
|
): GroupDetail {
|
||||||
|
val enrollment =
|
||||||
|
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||||
?: throw NotFoundException("Студент не входит в эту группу")
|
?: throw NotFoundException("Студент не входит в эту группу")
|
||||||
enrollments.delete(enrollment)
|
enrollments.delete(enrollment)
|
||||||
return detail(groupId)
|
return detail(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
|
fun import(
|
||||||
|
requestedName: String?,
|
||||||
|
filename: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): ImportResult {
|
||||||
val parsed = parser.parse(filename, bytes)
|
val parsed = parser.parse(filename, bytes)
|
||||||
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
||||||
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
||||||
@@ -143,50 +159,69 @@ class GroupService(
|
|||||||
val updated = mutableListOf<String>()
|
val updated = mutableListOf<String>()
|
||||||
|
|
||||||
parsed.students.forEach { parsedStudent ->
|
parsed.students.forEach { parsedStudent ->
|
||||||
val existing = students.findByStudentNo(parsedStudent.studentNo)
|
val student = upsertStudent(parsedStudent, added, updated)
|
||||||
val student = if (existing != null) {
|
enrollIntoGroup(group, student)
|
||||||
existing.lastName = parsedStudent.lastName
|
}
|
||||||
existing.firstName = parsedStudent.firstName
|
return ImportResult(group.id.toString(), group.name, added, updated)
|
||||||
existing.middleName = parsedStudent.middleName
|
}
|
||||||
existing.basis = parsedStudent.basis
|
|
||||||
existing.note = parsedStudent.note
|
/** Обновляет существующего студента или создаёт нового с временным паролем. */
|
||||||
|
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)
|
students.save(existing)
|
||||||
updated.add(parsedStudent.studentNo)
|
updated.add(parsed.studentNo)
|
||||||
existing
|
return existing
|
||||||
} else {
|
}
|
||||||
val password = Passwords.generate()
|
val password = Passwords.generate()
|
||||||
val account = accounts.save(
|
val account =
|
||||||
|
accounts.save(
|
||||||
Account(
|
Account(
|
||||||
login = parsedStudent.studentNo,
|
login = parsed.studentNo,
|
||||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||||
role = LmsRole.STUDENT,
|
role = LmsRole.STUDENT,
|
||||||
mustChangePassword = true,
|
mustChangePassword = true,
|
||||||
tempPassword = password,
|
tempPassword = password,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val created = Student(
|
val created =
|
||||||
studentNo = parsedStudent.studentNo,
|
Student(
|
||||||
lastName = parsedStudent.lastName,
|
studentNo = parsed.studentNo,
|
||||||
firstName = parsedStudent.firstName,
|
lastName = parsed.lastName,
|
||||||
middleName = parsedStudent.middleName,
|
firstName = parsed.firstName,
|
||||||
basis = parsedStudent.basis,
|
middleName = parsed.middleName,
|
||||||
note = parsedStudent.note,
|
basis = parsed.basis,
|
||||||
|
note = parsed.note,
|
||||||
)
|
)
|
||||||
created.account = account
|
created.account = account
|
||||||
students.save(created)
|
students.save(created)
|
||||||
added.add(
|
added.add(
|
||||||
Credential(
|
Credential(
|
||||||
id = account.id.toString(),
|
id = account.id.toString(),
|
||||||
studentNo = parsedStudent.studentNo,
|
studentNo = parsed.studentNo,
|
||||||
fullName = parsedStudent.fullName,
|
fullName = parsed.fullName,
|
||||||
password = password,
|
password = password,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Зачисляет студента в группу (или переводит из другой). */
|
||||||
|
private fun enrollIntoGroup(
|
||||||
|
group: GroupEntity,
|
||||||
|
student: Student,
|
||||||
|
) {
|
||||||
val accountId = student.accountId!!
|
val accountId = student.accountId!!
|
||||||
val enrollment = enrollments.findByStudent_AccountId(accountId)
|
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||||
if (enrollment == null) {
|
if (enrollment == null) {
|
||||||
enrollments.save(Enrollment(group = group, student = student))
|
enrollments.save(Enrollment(group = group, student = student))
|
||||||
} else {
|
} else {
|
||||||
@@ -194,12 +229,10 @@ class GroupService(
|
|||||||
enrollments.save(enrollment)
|
enrollments.save(enrollment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ImportResult(group.id.toString(), group.name, added, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||||
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
|
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||||
return GroupSummary(
|
return GroupSummary(
|
||||||
id = group.id.toString(),
|
id = group.id.toString(),
|
||||||
name = group.name,
|
name = group.name,
|
||||||
@@ -211,7 +244,10 @@ class GroupService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun detail(groupId: UUID): GroupDetail {
|
fun detail(groupId: UUID): GroupDetail {
|
||||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||||
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
|
val rows =
|
||||||
|
enrollments
|
||||||
|
.findAllByGroupId(groupId)
|
||||||
|
.map { enrollment ->
|
||||||
val student = enrollment.student!!
|
val student = enrollment.student!!
|
||||||
val account = student.account!!
|
val account = student.account!!
|
||||||
StudentRow(
|
StudentRow(
|
||||||
@@ -228,7 +264,9 @@ class GroupService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||||
val student = students.findById(studentAccountId)
|
val student =
|
||||||
|
students
|
||||||
|
.findById(studentAccountId)
|
||||||
.orElseThrow { NotFoundException("Студент не найден") }
|
.orElseThrow { NotFoundException("Студент не найден") }
|
||||||
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
||||||
val password = Passwords.generate()
|
val password = Passwords.generate()
|
||||||
@@ -240,16 +278,21 @@ class GroupService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun credentials(groupId: UUID, format: String): ExportFile {
|
fun credentials(
|
||||||
|
groupId: UUID,
|
||||||
|
format: String,
|
||||||
|
): ExportFile {
|
||||||
val detail = detail(groupId)
|
val detail = detail(groupId)
|
||||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||||
return when (format.lowercase()) {
|
return when (format.lowercase()) {
|
||||||
"csv" -> ExportFile(
|
"csv" ->
|
||||||
|
ExportFile(
|
||||||
bytes = CredentialsExporter.csv(detail.students),
|
bytes = CredentialsExporter.csv(detail.students),
|
||||||
filename = "credentials_$safeName.csv",
|
filename = "credentials_$safeName.csv",
|
||||||
contentType = "text/csv; charset=utf-8",
|
contentType = "text/csv; charset=utf-8",
|
||||||
)
|
)
|
||||||
"xlsx" -> ExportFile(
|
"xlsx" ->
|
||||||
|
ExportFile(
|
||||||
bytes = CredentialsExporter.xlsx(detail.students),
|
bytes = CredentialsExporter.xlsx(detail.students),
|
||||||
filename = "credentials_$safeName.xlsx",
|
filename = "credentials_$safeName.xlsx",
|
||||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
@@ -258,10 +301,11 @@ class GroupService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
|
private fun summary(group: GroupEntity): GroupSummary =
|
||||||
|
GroupSummary(
|
||||||
id = group.id.toString(),
|
id = group.id.toString(),
|
||||||
name = group.name,
|
name = group.name,
|
||||||
studentCount = enrollments.countByGroup_Id(group.id),
|
studentCount = enrollments.countByGroupId(group.id),
|
||||||
taskDurationMin = group.taskDurationMin,
|
taskDurationMin = group.taskDurationMin,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import java.util.UUID
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/students")
|
@RequestMapping("/api/v1/students")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class StudentController(private val groupService: GroupService) {
|
class StudentController(
|
||||||
|
private val groupService: GroupService,
|
||||||
|
) {
|
||||||
@PostMapping("/{id}/reset-password")
|
@PostMapping("/{id}/reset-password")
|
||||||
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
|
fun resetPassword(
|
||||||
groupService.resetPassword(id)
|
@PathVariable id: UUID,
|
||||||
|
): ResetPasswordResponse = groupService.resetPassword(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ data class ParsedGroup(
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
class StudentImportParser {
|
class StudentImportParser {
|
||||||
|
fun parse(
|
||||||
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
|
filename: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): ParsedGroup {
|
||||||
val lower = filename.lowercase()
|
val lower = filename.lowercase()
|
||||||
return when {
|
return when {
|
||||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||||
@@ -65,7 +67,10 @@ class StudentImportParser {
|
|||||||
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
|
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
|
private fun splitCsvLine(
|
||||||
|
line: String,
|
||||||
|
delimiter: Char,
|
||||||
|
): List<String> {
|
||||||
val result = mutableListOf<String>()
|
val result = mutableListOf<String>()
|
||||||
val current = StringBuilder()
|
val current = StringBuilder()
|
||||||
var inQuotes = false
|
var inQuotes = false
|
||||||
@@ -83,26 +88,47 @@ class StudentImportParser {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
|
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) } }
|
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
||||||
if (headerIndex < 0) {
|
if (headerIndex < 0) {
|
||||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
||||||
}
|
}
|
||||||
val columns = mapColumns(rows[headerIndex])
|
return headerIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireColumns(header: List<String>): Map<String, Int> {
|
||||||
|
val columns = mapColumns(header)
|
||||||
if (columns["studentNo"] == null) {
|
if (columns["studentNo"] == null) {
|
||||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
||||||
}
|
}
|
||||||
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
|
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()
|
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||||
val studentNo = normalizeNumber(cell("studentNo"))
|
val studentNo = normalizeNumber(cell("studentNo"))
|
||||||
val lastName = cell("lastName")
|
val lastName = cell("lastName")
|
||||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
if (studentNo.isBlank() && lastName.isBlank()) return null
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
if (studentNo.isBlank()) {
|
if (studentNo.isBlank()) {
|
||||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||||
}
|
}
|
||||||
ParsedStudent(
|
return ParsedStudent(
|
||||||
studentNo = studentNo,
|
studentNo = studentNo,
|
||||||
lastName = lastName,
|
lastName = lastName,
|
||||||
firstName = cell("firstName"),
|
firstName = cell("firstName"),
|
||||||
@@ -111,23 +137,24 @@ class StudentImportParser {
|
|||||||
note = cell("note"),
|
note = cell("note"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (students.isEmpty()) {
|
|
||||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
|
||||||
}
|
|
||||||
return ParsedGroup(groupNameFrom(filename, rows), students)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isStudentHeader(value: String): Boolean {
|
private fun isStudentHeader(value: String): Boolean {
|
||||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||||
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
|
return normalized.startsWith("студен") ||
|
||||||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
|
normalized.startsWith("студ.") ||
|
||||||
|
normalized.startsWith("зачёт") ||
|
||||||
|
normalized.startsWith("зачет")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapColumns(header: List<String>): Map<String, Int> {
|
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||||
val result = mutableMapOf<String, Int>()
|
val result = mutableMapOf<String, Int>()
|
||||||
header.forEachIndexed { index, raw ->
|
header.forEachIndexed { index, raw ->
|
||||||
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
||||||
fun putIfAbsent(key: String, vararg prefixes: String) {
|
|
||||||
|
fun putIfAbsent(
|
||||||
|
key: String,
|
||||||
|
vararg prefixes: String,
|
||||||
|
) {
|
||||||
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
||||||
result[key] = index
|
result[key] = index
|
||||||
}
|
}
|
||||||
@@ -142,10 +169,12 @@ class StudentImportParser {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeNumber(value: String): String =
|
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||||
if (value.endsWith(".0")) value.dropLast(2) else value
|
|
||||||
|
|
||||||
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
|
private fun groupNameFrom(
|
||||||
|
filename: String,
|
||||||
|
rows: List<List<String>>,
|
||||||
|
): String {
|
||||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
||||||
for (row in rows.take(12)) {
|
for (row in rows.take(12)) {
|
||||||
for (cell in row) {
|
for (cell in row) {
|
||||||
|
|||||||
@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(JwtProperties::class)
|
@EnableConfigurationProperties(JwtProperties::class)
|
||||||
class JwtConfig(private val keyStore: JwtKeyStore) {
|
class JwtConfig(
|
||||||
|
private val keyStore: JwtKeyStore,
|
||||||
|
) {
|
||||||
@Bean
|
@Bean
|
||||||
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
|
fun rsaKey(): RSAKey =
|
||||||
|
RSAKey
|
||||||
|
.Builder(keyStore.publicKey)
|
||||||
.privateKey(keyStore.privateKey)
|
.privateKey(keyStore.privateKey)
|
||||||
.keyID("lms")
|
.keyID("lms")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
|
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||||
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
|
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.
|
* in the `settings` table, so no private key ever lives in the repository.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class JwtKeyStore(private val settings: SettingRepository) {
|
class JwtKeyStore(
|
||||||
|
private val settings: SettingRepository,
|
||||||
|
) {
|
||||||
lateinit var privateKey: RSAPrivateKey
|
lateinit var privateKey: RSAPrivateKey
|
||||||
|
|
||||||
lateinit var publicKey: RSAPublicKey
|
lateinit var publicKey: RSAPublicKey
|
||||||
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toPem(label: String, der: ByteArray): String {
|
private fun toPem(
|
||||||
|
label: String,
|
||||||
|
der: ByteArray,
|
||||||
|
): String {
|
||||||
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
|
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
|
||||||
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
|
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
class SecurityConfig {
|
class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
http
|
http
|
||||||
@@ -26,7 +25,8 @@ class SecurityConfig {
|
|||||||
.cors { it.disable() }
|
.cors { it.disable() }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
auth.requestMatchers(
|
auth
|
||||||
|
.requestMatchers(
|
||||||
"/api/v1/auth/health",
|
"/api/v1/auth/health",
|
||||||
"/api/v1/auth/setup",
|
"/api/v1/auth/setup",
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
@@ -37,8 +37,7 @@ class SecurityConfig {
|
|||||||
"/swagger-ui.html",
|
"/swagger-ui.html",
|
||||||
).permitAll()
|
).permitAll()
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}.oauth2ResourceServer { resource ->
|
||||||
.oauth2ResourceServer { resource ->
|
|
||||||
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
||||||
}
|
}
|
||||||
return http.build()
|
return http.build()
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ import org.springframework.test.context.DynamicPropertySource
|
|||||||
import org.testcontainers.containers.PostgreSQLContainer
|
import org.testcontainers.containers.PostgreSQLContainer
|
||||||
import org.testcontainers.junit.jupiter.Container
|
import org.testcontainers.junit.jupiter.Container
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
import ru.ysu.lms.identity.auth.AuthService
|
|
||||||
import ru.ysu.lms.common.UnauthorizedException
|
import ru.ysu.lms.common.UnauthorizedException
|
||||||
|
import ru.ysu.lms.identity.auth.AuthService
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
class IdentitySmokeTest {
|
class IdentitySmokeTest {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Container
|
@Container
|
||||||
val pg = PostgreSQLContainer("postgres:18").apply {
|
val pg =
|
||||||
|
PostgreSQLContainer("postgres:18").apply {
|
||||||
withDatabaseName("lms")
|
withDatabaseName("lms")
|
||||||
withUsername("lms")
|
withUsername("lms")
|
||||||
withPassword("lms")
|
withPassword("lms")
|
||||||
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `setup creates admin and login works`() {
|
fun `setup creates admin and login works`() {
|
||||||
val token = authService.setup(
|
val token =
|
||||||
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
|
authService.setup(
|
||||||
|
ru.ysu.lms.identity.auth
|
||||||
|
.SetupRequest("admin", "test12345", "test12345"),
|
||||||
)
|
)
|
||||||
assert(token.token.isNotBlank()) { "token should not be blank" }
|
assert(token.token.isNotBlank()) { "token should not be blank" }
|
||||||
assert(token.role == "ADMIN")
|
assert(token.role == "ADMIN")
|
||||||
|
|
||||||
val login = authService.login(
|
val login =
|
||||||
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
|
authService.login(
|
||||||
|
ru.ysu.lms.identity.auth
|
||||||
|
.LoginRequest("admin", "test12345"),
|
||||||
)
|
)
|
||||||
assert(login.token.isNotBlank())
|
assert(login.token.isNotBlank())
|
||||||
|
|
||||||
val ex = try {
|
val ex =
|
||||||
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
|
try {
|
||||||
|
authService.login(
|
||||||
|
ru.ysu.lms.identity.auth
|
||||||
|
.LoginRequest("admin", "bad"),
|
||||||
|
)
|
||||||
null
|
null
|
||||||
} catch (e: UnauthorizedException) { e }
|
} catch (e: UnauthorizedException) {
|
||||||
|
e
|
||||||
|
}
|
||||||
assert(ex != null) { "bad password should throw" }
|
assert(ex != null) { "bad password should throw" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,16 +12,28 @@ class AcademicClient(
|
|||||||
) {
|
) {
|
||||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||||
|
|
||||||
fun getLab(labId: String, token: String): LabInfo =
|
fun getLab(
|
||||||
client.get().uri("/api/v1/labs/{id}", labId)
|
labId: String,
|
||||||
|
token: String,
|
||||||
|
): LabInfo =
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/labs/{id}", labId)
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.retrieve().body(LabInfo::class.java)
|
.retrieve()
|
||||||
|
.body(LabInfo::class.java)
|
||||||
?: throw NotFoundException("Лабораторная не найдена")
|
?: throw NotFoundException("Лабораторная не найдена")
|
||||||
|
|
||||||
fun getLabSummary(labId: String, token: String): LabInfo =
|
fun getLabSummary(
|
||||||
client.get().uri("/api/v1/labs/{id}", labId)
|
labId: String,
|
||||||
|
token: String,
|
||||||
|
): LabInfo =
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/labs/{id}", labId)
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.retrieve().body(LabInfo::class.java)
|
.retrieve()
|
||||||
|
.body(LabInfo::class.java)
|
||||||
?: throw NotFoundException("Лабораторная не найдена")
|
?: throw NotFoundException("Лабораторная не найдена")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +43,25 @@ class IdentityClient(
|
|||||||
) {
|
) {
|
||||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||||
|
|
||||||
fun getGroup(groupId: String, token: String): GroupInfo =
|
fun getGroup(
|
||||||
client.get().uri("/api/v1/groups/{id}", groupId)
|
groupId: String,
|
||||||
|
token: String,
|
||||||
|
): GroupInfo =
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/groups/{id}", groupId)
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.retrieve().body(GroupInfo::class.java)
|
.retrieve()
|
||||||
|
.body(GroupInfo::class.java)
|
||||||
?: throw NotFoundException("Группа не найдена")
|
?: throw NotFoundException("Группа не найдена")
|
||||||
|
|
||||||
fun listGroups(token: String): List<Map<String, Any>> {
|
fun listGroups(token: String): List<Map<String, Any>> {
|
||||||
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
|
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
|
||||||
return client.get().uri("/api/v1/groups")
|
return client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/groups")
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.retrieve().body(type) ?: emptyList()
|
.retrieve()
|
||||||
|
.body(type) ?: emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
package ru.ysu.lms.progress
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String)
|
data class DefenseAssignPayload(
|
||||||
|
val studentId: String,
|
||||||
|
val labId: String,
|
||||||
|
val assignmentMd: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class DefenseAdminDto(
|
data class DefenseAdminDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/defenses")
|
@RequestMapping("/api/v1/defenses")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
class DefenseAdminController(private val service: DefenseService) {
|
class DefenseAdminController(
|
||||||
|
private val service: DefenseService,
|
||||||
|
) {
|
||||||
@PostMapping("/assign")
|
@PostMapping("/assign")
|
||||||
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto =
|
fun assign(
|
||||||
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
@RequestBody body: DefenseAssignPayload,
|
||||||
|
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||||
|
|
||||||
@DeleteMapping("/assign")
|
@DeleteMapping("/assign")
|
||||||
fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) {
|
fun removeAssignment(
|
||||||
|
@RequestParam studentId: String,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
) {
|
||||||
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
|
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> =
|
fun list(
|
||||||
|
@RequestParam labId: UUID,
|
||||||
|
): List<DefenseAdminDto> =
|
||||||
service.listForLab(labId).map {
|
service.listForLab(labId).map {
|
||||||
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd,
|
DefenseAdminDto(
|
||||||
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft)
|
it.id,
|
||||||
|
it.studentId,
|
||||||
|
it.labId,
|
||||||
|
it.status,
|
||||||
|
it.code,
|
||||||
|
it.assignmentMd,
|
||||||
|
it.startedAt,
|
||||||
|
it.readyAt,
|
||||||
|
it.readyReason,
|
||||||
|
it.secondsLeft,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/issue-code")
|
@PostMapping("/{id}/issue-code")
|
||||||
fun issueCode(@PathVariable id: UUID): Map<String, String> = mapOf("code" to service.issueCode(id))
|
fun issueCode(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||||
|
|
||||||
@PostMapping("/{id}/reset")
|
@PostMapping("/{id}/reset")
|
||||||
fun reset(@PathVariable id: UUID) = service.reset(id)
|
fun reset(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
) = service.reset(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
|
|||||||
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/me/defenses")
|
@RequestMapping("/api/v1/me/defenses")
|
||||||
class DefenseMyController(private val service: DefenseService) {
|
class DefenseMyController(
|
||||||
|
private val service: DefenseService,
|
||||||
data class StartByCodePayload(val code: String)
|
) {
|
||||||
|
data class StartByCodePayload(
|
||||||
|
val code: String,
|
||||||
|
)
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> =
|
fun my(
|
||||||
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||||
|
|
||||||
@GetMapping("/{labId}")
|
@GetMapping("/{labId}")
|
||||||
fun one(
|
fun one(
|
||||||
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
|
|||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
|
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
|
||||||
|
|
||||||
private fun hideCode(d: DefenseDto): DefenseDto =
|
private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||||
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
|
||||||
|
|
||||||
@PostMapping("/start")
|
@PostMapping("/start")
|
||||||
fun start(
|
fun start(
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ data class DefenseDto(
|
|||||||
class DefenseService(
|
class DefenseService(
|
||||||
private val sessions: DefenseSessionRepository,
|
private val sessions: DefenseSessionRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DURATION: Duration = Duration.ofMinutes(30)
|
val DURATION: Duration = Duration.ofMinutes(30)
|
||||||
private val random = SecureRandom()
|
private val random = SecureRandom()
|
||||||
@@ -55,7 +54,11 @@ class DefenseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto {
|
fun assign(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
assignmentMd: String,
|
||||||
|
): DefenseDto {
|
||||||
val existing = sessions.findByStudentIdAndLabId(studentId, labId)
|
val existing = sessions.findByStudentIdAndLabId(studentId, labId)
|
||||||
val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
|
val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
|
||||||
// смена задания сбрасывает активную попытку защиты
|
// смена задания сбрасывает активную попытку защиты
|
||||||
@@ -106,19 +109,44 @@ class DefenseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun startByCode(studentId: UUID, code: String): DefenseDto {
|
fun startByCode(
|
||||||
|
studentId: UUID,
|
||||||
|
code: String,
|
||||||
|
): DefenseDto {
|
||||||
syncTimeouts()
|
syncTimeouts()
|
||||||
val s = sessions.findByCode(code.trim())
|
val s = requireWaitingSession(studentId, code)
|
||||||
?: throw BadRequestException("Код не найден")
|
|
||||||
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
|
|
||||||
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
|
|
||||||
s.status = DefenseStatus.started.name
|
s.status = DefenseStatus.started.name
|
||||||
s.startedAt = Instant.now()
|
s.startedAt = Instant.now()
|
||||||
return toDto(sessions.save(s))
|
return toDto(sessions.save(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requireWaitingSession(
|
||||||
|
studentId: UUID,
|
||||||
|
code: String,
|
||||||
|
): DefenseSession {
|
||||||
|
val session = findSessionByCode(code)
|
||||||
|
requireOwnerAndWaiting(session, studentId)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSessionByCode(code: String): DefenseSession =
|
||||||
|
sessions.findByCode(code.trim()) ?: throw BadRequestException("Код не найден")
|
||||||
|
|
||||||
|
private fun requireOwnerAndWaiting(
|
||||||
|
session: DefenseSession,
|
||||||
|
studentId: UUID,
|
||||||
|
) {
|
||||||
|
if (session.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
|
||||||
|
if (session.status != DefenseStatus.waiting.name) {
|
||||||
|
throw BadRequestException("Защита по этому коду уже начата")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun finish(studentId: UUID, sessionId: UUID): DefenseDto {
|
fun finish(
|
||||||
|
studentId: UUID,
|
||||||
|
sessionId: UUID,
|
||||||
|
): DefenseDto {
|
||||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||||
@@ -135,13 +163,19 @@ class DefenseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun mySession(studentId: UUID, labId: UUID): DefenseDto? {
|
fun mySession(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): DefenseDto? {
|
||||||
syncTimeouts()
|
syncTimeouts()
|
||||||
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
|
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun resetByStudentLab(studentId: UUID, labId: UUID) {
|
fun resetByStudentLab(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
) {
|
||||||
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
|
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
|
||||||
s.status = DefenseStatus.waiting.name
|
s.status = DefenseStatus.waiting.name
|
||||||
s.code = null
|
s.code = null
|
||||||
@@ -152,11 +186,18 @@ class DefenseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun toDto(s: DefenseSession): DefenseDto {
|
private fun toDto(s: DefenseSession): DefenseDto {
|
||||||
val left = if (s.status == DefenseStatus.started.name) {
|
val left =
|
||||||
|
if (s.status == DefenseStatus.started.name) {
|
||||||
val started = s.startedAt ?: Instant.now()
|
val started = s.startedAt ?: Instant.now()
|
||||||
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0)
|
val remain =
|
||||||
|
java.time.Duration
|
||||||
|
.between(Instant.now(), started.plus(DURATION))
|
||||||
|
.seconds
|
||||||
|
.coerceAtLeast(0)
|
||||||
remain
|
remain
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
return DefenseDto(
|
return DefenseDto(
|
||||||
id = s.id.toString(),
|
id = s.id.toString(),
|
||||||
studentId = s.studentId.toString(),
|
studentId = s.studentId.toString(),
|
||||||
|
|||||||
@@ -136,8 +136,15 @@ data class GradeSetRequest(
|
|||||||
val comments: List<CommentInput> = emptyList(),
|
val comments: List<CommentInput> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CommentInput(val text: String, val tone: String = "neutral")
|
data class CommentInput(
|
||||||
data class CommentOut(val text: String, val tone: String)
|
val text: String,
|
||||||
|
val tone: String = "neutral",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CommentOut(
|
||||||
|
val text: String,
|
||||||
|
val tone: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class GradeResponse(
|
data class GradeResponse(
|
||||||
val studentId: String,
|
val studentId: String,
|
||||||
|
|||||||
@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import ru.ysu.lms.progress.domain.GradePublication
|
import ru.ysu.lms.progress.domain.GradePublication
|
||||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
|
||||||
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class GradePublicationService(private val repo: GradePublicationRepository) {
|
class GradePublicationService(
|
||||||
|
private val repo: GradePublicationRepository,
|
||||||
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
|
fun setPublished(
|
||||||
|
groupId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
published: Boolean,
|
||||||
|
) {
|
||||||
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
||||||
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
||||||
if (!published && existing != null) repo.delete(existing)
|
if (!published && existing != null) repo.delete(existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun isPublished(groupId: UUID, labId: UUID): Boolean =
|
fun isPublished(
|
||||||
repo.findByLabIdAndGroupId(labId, groupId) != null
|
groupId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun publishedGroupIdsForLab(labId: UUID): List<String> =
|
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
|
||||||
repo.findByLabId(labId).map { it.groupId.toString() }
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
|
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||||
repo.findByGroupId(groupId).map { it.labId.toString() }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import java.net.URLEncoder
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import java.nio.charset.StandardCharsets
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1")
|
||||||
class ProgressController(
|
class ProgressController(
|
||||||
private val service: ProgressService,
|
private val service: ProgressService,
|
||||||
|
private val grades: GradeService,
|
||||||
private val publications: GradePublicationService,
|
private val publications: GradePublicationService,
|
||||||
private val submissions: SubmissionService,
|
|
||||||
private val academic: AcademicClient,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping("/me/assignments")
|
@GetMapping("/me/assignments")
|
||||||
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
fun meAssignments(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): List<MeAssignmentResponse> {
|
||||||
val sid = UUID.fromString(jwt.subject)
|
val sid = UUID.fromString(jwt.subject)
|
||||||
return service.getStudentAssignments(sid).mapNotNull { va ->
|
return service.getStudentAssignments(sid).mapNotNull { va ->
|
||||||
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
|
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
|
||||||
@@ -31,9 +30,7 @@ class ProgressController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me/labs")
|
@GetMapping("/me/labs")
|
||||||
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
|
fun meLabs(): List<Map<String, Any>> = listOf()
|
||||||
return listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/levels")
|
@PostMapping("/levels")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
@@ -44,7 +41,9 @@ class ProgressController(
|
|||||||
|
|
||||||
@GetMapping("/levels")
|
@GetMapping("/levels")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
|
fun getLevels(
|
||||||
|
@RequestParam labId: UUID,
|
||||||
|
): List<LevelAssignmentResponse> =
|
||||||
service.getLevelsForLab(labId).map { sll ->
|
service.getLevelsForLab(labId).map { sll ->
|
||||||
LevelAssignmentResponse(
|
LevelAssignmentResponse(
|
||||||
studentId = sll.studentId.toString(),
|
studentId = sll.studentId.toString(),
|
||||||
@@ -77,84 +76,50 @@ class ProgressController(
|
|||||||
|
|
||||||
@PostMapping("/grades")
|
@PostMapping("/grades")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
|
fun setGrade(
|
||||||
|
@Valid @RequestBody body: GradeSetRequest,
|
||||||
|
): GradeResponse = grades.setGrade(body)
|
||||||
|
|
||||||
@GetMapping("/grades")
|
@GetMapping("/grades")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
fun listGrades(): List<GradeResponse> = grades.listGrades()
|
||||||
|
|
||||||
@PostMapping("/grades/publish")
|
@PostMapping("/grades/publish")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
|
fun publishGrades(
|
||||||
|
@RequestParam labId: java.util.UUID,
|
||||||
|
@RequestParam groupId: java.util.UUID,
|
||||||
|
@RequestParam published: Boolean,
|
||||||
|
) {
|
||||||
publications.setPublished(groupId, labId, published)
|
publications.setPublished(groupId, labId, published)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/grades/published")
|
@GetMapping("/grades/published")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
|
fun gradesPublished(
|
||||||
publications.isPublished(groupId, labId)
|
@RequestParam("labId") labId: java.util.UUID,
|
||||||
|
@RequestParam groupId: java.util.UUID,
|
||||||
|
): Boolean = publications.isPublished(groupId, labId)
|
||||||
|
|
||||||
@GetMapping("/grades/publications")
|
@GetMapping("/grades/publications")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
|
fun groupPublications(
|
||||||
publications.publishedLabIdsForGroup(groupId)
|
@RequestParam groupId: java.util.UUID,
|
||||||
|
): List<String> = publications.publishedLabIdsForGroup(groupId)
|
||||||
|
|
||||||
@GetMapping("/me/grades")
|
@GetMapping("/me/grades")
|
||||||
fun myGrades(
|
fun myGrades(
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||||
): List<GradeResponse> {
|
): List<GradeResponse> {
|
||||||
val all = service.listGrades().filter { it.studentId == jwt.subject }
|
val all = grades.listGrades().filter { it.studentId == jwt.subject }
|
||||||
if (groupId == null) return emptyList()
|
if (groupId == null) return emptyList()
|
||||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/comment-templates")
|
@GetMapping("/comment-templates")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
|
fun suggestComments(
|
||||||
|
@RequestParam q: String,
|
||||||
@PostMapping("/me/submissions/{labId}")
|
): List<CommentOut> = grades.suggestComments(q)
|
||||||
fun submit(
|
|
||||||
@PathVariable labId: UUID,
|
|
||||||
@RequestPart("files") files: List<MultipartFile>,
|
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
|
||||||
): List<SubmissionDto> {
|
|
||||||
val sid = UUID.fromString(jwt.subject)
|
|
||||||
val all = submissions.submit(sid, labId, files, jwt.tokenValue)
|
|
||||||
return submissions.mySubmissions(sid, labId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/me/submissions")
|
|
||||||
fun mySubmissions(
|
|
||||||
@RequestParam labId: UUID,
|
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
|
||||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
|
||||||
|
|
||||||
@GetMapping("/me/submissions/{id}/download")
|
|
||||||
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
|
||||||
val sid = UUID.fromString(jwt.subject)
|
|
||||||
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
|
|
||||||
return attachment(bytes, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/submissions")
|
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
|
||||||
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
|
|
||||||
submissions.latestPerStudent(labId)
|
|
||||||
|
|
||||||
@GetMapping("/submissions/{id}/download")
|
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
|
||||||
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
|
||||||
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
|
|
||||||
return attachment(bytes, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
|
|
||||||
ResponseEntity.ok()
|
|
||||||
.header(
|
|
||||||
HttpHeaders.CONTENT_DISPOSITION,
|
|
||||||
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
|
|
||||||
)
|
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
|
||||||
.body(bytes)
|
|
||||||
}
|
}
|
||||||
@@ -3,34 +3,25 @@ package ru.ysu.lms.progress
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import ru.ysu.lms.progress.domain.CommentTemplate
|
|
||||||
import ru.ysu.lms.progress.domain.GradeComment
|
|
||||||
import ru.ysu.lms.progress.domain.LabGrade
|
|
||||||
import ru.ysu.lms.progress.domain.LabGradeKey
|
|
||||||
import ru.ysu.lms.progress.domain.StudentLabLevel
|
import ru.ysu.lms.progress.domain.StudentLabLevel
|
||||||
|
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
import ru.ysu.lms.progress.domain.VariantAssignment
|
||||||
import ru.ysu.lms.progress.repo.CommentTemplateRepository
|
|
||||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
|
||||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
|
||||||
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
||||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ProgressService(
|
class ProgressService(
|
||||||
private val levels: StudentLabLevelRepository,
|
private val levels: StudentLabLevelRepository,
|
||||||
private val assignments: VariantAssignmentRepository,
|
private val assignments: VariantAssignmentRepository,
|
||||||
private val grades: LabGradeRepository,
|
|
||||||
private val templates: CommentTemplateRepository,
|
|
||||||
private val gradeComments: GradeCommentRepository,
|
|
||||||
private val academic: AcademicClient,
|
private val academic: AcademicClient,
|
||||||
private val identity: IdentityClient,
|
private val identity: IdentityClient,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
|
fun setLevel(
|
||||||
|
request: LevelAssignmentRequest,
|
||||||
|
token: String,
|
||||||
|
): LevelAssignmentResponse {
|
||||||
val sid = UUID.fromString(request.studentId)
|
val sid = UUID.fromString(request.studentId)
|
||||||
val lid = UUID.fromString(request.labId)
|
val lid = UUID.fromString(request.labId)
|
||||||
val key = StudentLabLevelKey(sid, lid)
|
val key = StudentLabLevelKey(sid, lid)
|
||||||
@@ -46,17 +37,24 @@ class ProgressService(
|
|||||||
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
||||||
if (level != null && level.variants.isNotEmpty()) {
|
if (level != null && level.variants.isNotEmpty()) {
|
||||||
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
||||||
val currentInLevel = assignment?.variantId?.toString()
|
val currentInLevel =
|
||||||
|
assignment
|
||||||
|
?.variantId
|
||||||
|
?.toString()
|
||||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||||
if (!currentInLevel) {
|
if (!currentInLevel) {
|
||||||
val used = assignments.findByLabId(lid)
|
val used =
|
||||||
|
assignments
|
||||||
|
.findByLabId(lid)
|
||||||
.map { it.variantId.toString() }
|
.map { it.variantId.toString() }
|
||||||
.filter { it != assignment?.variantId?.toString() }
|
.filter { it != assignment?.variantId?.toString() }
|
||||||
.toMutableSet()
|
.toMutableSet()
|
||||||
val pool = level.variants.map { it.id }
|
val pool = level.variants.map { it.id }
|
||||||
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
|
val chosen =
|
||||||
|
pool.filter { it !in used }.shuffled().firstOrNull()
|
||||||
?: pool.shuffled().first()
|
?: pool.shuffled().first()
|
||||||
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
val target =
|
||||||
|
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||||
variantId = UUID.fromString(chosen)
|
variantId = UUID.fromString(chosen)
|
||||||
manual = false
|
manual = false
|
||||||
}
|
}
|
||||||
@@ -72,98 +70,37 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
|
fun getLevel(
|
||||||
levels.findByStudentIdAndLabId(studentId, labId)
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
|
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
|
||||||
levels.findByLabId(labId)
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun distribute(request: DistributeRequest, token: String): DistributeResult {
|
fun distribute(
|
||||||
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
|
request: DistributeRequest,
|
||||||
.map { it.trim() }
|
token: String,
|
||||||
.filter { it.isNotBlank() }
|
): DistributeResult {
|
||||||
.distinct()
|
val labIds = resolveLabIds(request)
|
||||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
|
||||||
|
|
||||||
val force = request.force == true
|
val force = request.force == true
|
||||||
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet()
|
val allStudents = collectStudentIds(request, token)
|
||||||
val studentIds = mutableListOf<UUID>()
|
|
||||||
for (g in identity.listGroups(token)) {
|
|
||||||
val gid = g["id"] as String
|
|
||||||
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
|
||||||
for (s in identity.getGroup(gid, token).students) {
|
|
||||||
studentIds.add(UUID.fromString(s.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val allStudents = studentIds.distinct()
|
|
||||||
|
|
||||||
var assignedTotal = 0
|
var assignedTotal = 0
|
||||||
var totalStudents = 0
|
var totalStudents = 0
|
||||||
var lastLabId: String? = null
|
var lastLabId: String? = null
|
||||||
var lastLabTitle: String? = null
|
var lastLabTitle: String? = null
|
||||||
|
|
||||||
for (labIdStr in labIds) {
|
for (labIdStr in labIds) {
|
||||||
val labId = UUID.fromString(labIdStr)
|
val labId = UUID.fromString(labIdStr)
|
||||||
val lab = academic.getLab(labIdStr, token)
|
val lab = academic.getLab(labIdStr, token)
|
||||||
|
if (force) clearAutoAssignments(labId, allStudents)
|
||||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
val kept = keepExisting(labId, allStudents)
|
||||||
// закреплённые вручную варианты остаются как есть.
|
assignedTotal += kept.size
|
||||||
if (force) {
|
assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
|
||||||
assignments.findByLabId(labId)
|
|
||||||
.filter { it.studentId in allStudents && !it.manual }
|
|
||||||
.forEach { assignments.delete(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val kept = mutableMapOf<UUID, VariantAssignment>()
|
|
||||||
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
|
|
||||||
assignments.findByLabId(labId)
|
|
||||||
.filter { it.studentId in allStudents }
|
|
||||||
.forEach { kept[it.studentId] = it }
|
|
||||||
|
|
||||||
val toAssign = allStudents.filterNot { kept.containsKey(it) }
|
|
||||||
|
|
||||||
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
|
|
||||||
// При этом очередь одна общая для всех выбранных групп: круг не
|
|
||||||
// «перезапускается» на каждой группе, а продолжается по наименее
|
|
||||||
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
|
|
||||||
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
|
|
||||||
val defaultLevel = lab.levels.firstOrNull()?.id
|
|
||||||
|
|
||||||
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
|
|
||||||
val usage = assignments.findByLabId(labId)
|
|
||||||
.groupingBy { it.variantId.toString() }
|
|
||||||
.eachCount()
|
|
||||||
.toMutableMap()
|
|
||||||
|
|
||||||
for (sid in toAssign.shuffled()) {
|
|
||||||
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
|
|
||||||
?: defaultLevel
|
|
||||||
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
|
|
||||||
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
|
||||||
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
|
||||||
if (pool.isEmpty()) continue
|
|
||||||
val minCount = pool.minOf { usage[it] ?: 0 }
|
|
||||||
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
|
|
||||||
assignments.save(
|
|
||||||
VariantAssignment(
|
|
||||||
studentId = sid,
|
|
||||||
labId = labId,
|
|
||||||
variantId = UUID.fromString(chosen),
|
|
||||||
manual = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
usage[chosen] = (usage[chosen] ?: 0) + 1
|
|
||||||
assignedTotal++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalStudents += allStudents.size
|
totalStudents += allStudents.size
|
||||||
lastLabId = labIdStr
|
lastLabId = labIdStr
|
||||||
lastLabTitle = lab.title
|
lastLabTitle = lab.title
|
||||||
}
|
}
|
||||||
|
|
||||||
return DistributeResult(
|
return DistributeResult(
|
||||||
labs = labIds.size,
|
labs = labIds.size,
|
||||||
assigned = assignedTotal,
|
assigned = assignedTotal,
|
||||||
@@ -173,16 +110,110 @@ class ProgressService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveLabIds(request: DistributeRequest): List<String> {
|
||||||
|
val labIds =
|
||||||
|
request.labIds
|
||||||
|
.orEmpty()
|
||||||
|
.ifEmpty { listOfNotNull(request.labId) }
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||||
|
return labIds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectStudentIds(
|
||||||
|
request: DistributeRequest,
|
||||||
|
token: String,
|
||||||
|
): List<UUID> {
|
||||||
|
val groupFilter =
|
||||||
|
request.groupIds
|
||||||
|
.orEmpty()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet()
|
||||||
|
val studentIds = mutableListOf<UUID>()
|
||||||
|
for (g in identity.listGroups(token)) {
|
||||||
|
val gid = g["id"] as String
|
||||||
|
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
||||||
|
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
|
||||||
|
}
|
||||||
|
return studentIds.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAutoAssignments(
|
||||||
|
labId: UUID,
|
||||||
|
allStudents: List<UUID>,
|
||||||
|
) {
|
||||||
|
assignments
|
||||||
|
.findByLabId(labId)
|
||||||
|
.filter { it.studentId in allStudents && !it.manual }
|
||||||
|
.forEach { assignments.delete(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keepExisting(
|
||||||
|
labId: UUID,
|
||||||
|
allStudents: List<UUID>,
|
||||||
|
): Map<UUID, VariantAssignment> {
|
||||||
|
val kept = mutableMapOf<UUID, VariantAssignment>()
|
||||||
|
assignments
|
||||||
|
.findByLabId(labId)
|
||||||
|
.filter { it.studentId in allStudents }
|
||||||
|
.forEach { kept[it.studentId] = it }
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
|
||||||
|
private fun assignNew(
|
||||||
|
lab: LabInfo,
|
||||||
|
labId: UUID,
|
||||||
|
toAssign: List<UUID>,
|
||||||
|
): Int {
|
||||||
|
val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
|
||||||
|
val defaultLevel = lab.levels.firstOrNull()?.id
|
||||||
|
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
|
||||||
|
val usage =
|
||||||
|
assignments
|
||||||
|
.findByLabId(labId)
|
||||||
|
.groupingBy { it.variantId.toString() }
|
||||||
|
.eachCount()
|
||||||
|
.toMutableMap()
|
||||||
|
var assigned = 0
|
||||||
|
for (sid in toAssign.shuffled()) {
|
||||||
|
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
|
||||||
|
val pool = pickPool(levelId, poolsByLevel, defaultLevel)
|
||||||
|
if (pool.isEmpty()) continue
|
||||||
|
val minCount = pool.minOf { id -> usage[id] ?: 0 }
|
||||||
|
val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
|
||||||
|
assignments.save(
|
||||||
|
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
|
||||||
|
)
|
||||||
|
usage[chosen] = (usage[chosen] ?: 0) + 1
|
||||||
|
assigned++
|
||||||
|
}
|
||||||
|
return assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickPool(
|
||||||
|
levelId: String?,
|
||||||
|
poolsByLevel: Map<String, List<String>>,
|
||||||
|
defaultLevel: String?,
|
||||||
|
): List<String> =
|
||||||
|
levelId
|
||||||
|
?.let { poolsByLevel[it] }
|
||||||
|
.orEmpty()
|
||||||
|
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
||||||
|
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
||||||
assignments.findByLabId(labId)
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun assign(request: AssignRequest): VariantAssignment {
|
fun assign(request: AssignRequest): VariantAssignment {
|
||||||
val sid = UUID.fromString(request.studentId)
|
val sid = UUID.fromString(request.studentId)
|
||||||
val lid = UUID.fromString(request.labId)
|
val lid = UUID.fromString(request.labId)
|
||||||
val existing = assignments.findByStudentIdAndLabId(sid, lid)
|
val existing = assignments.findByStudentIdAndLabId(sid, lid)
|
||||||
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
val entity =
|
||||||
|
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||||
variantId = UUID.fromString(request.variantId)
|
variantId = UUID.fromString(request.variantId)
|
||||||
manual = true
|
manual = true
|
||||||
}
|
}
|
||||||
@@ -190,7 +221,10 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
|
fun assignWithInfo(
|
||||||
|
request: AssignRequest,
|
||||||
|
token: String,
|
||||||
|
): VariantAssignmentResponse {
|
||||||
assign(request)
|
assign(request)
|
||||||
val lab = academic.getLab(request.labId, token)
|
val lab = academic.getLab(request.labId, token)
|
||||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
|
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
|
||||||
@@ -204,7 +238,10 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
|
fun assignmentsForLabEnriched(
|
||||||
|
labId: UUID,
|
||||||
|
token: String,
|
||||||
|
): List<VariantAssignmentResponse> {
|
||||||
val lab = academic.getLab(labId.toString(), token)
|
val lab = academic.getLab(labId.toString(), token)
|
||||||
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
|
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
|
||||||
return assignments.findByLabId(labId).map { va ->
|
return assignments.findByLabId(labId).map { va ->
|
||||||
@@ -220,76 +257,22 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
|
||||||
assignments.findByStudentId(studentId)
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
|
||||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
|
||||||
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
|
||||||
}
|
|
||||||
val sid = UUID.fromString(request.studentId)
|
|
||||||
val lid = UUID.fromString(request.labId)
|
|
||||||
val key = LabGradeKey(sid, lid)
|
|
||||||
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
|
||||||
grade.score = request.score
|
|
||||||
grades.save(grade)
|
|
||||||
replaceComments(sid, lid, request.comments)
|
|
||||||
return GradeResponse(request.studentId, request.labId, request.score,
|
|
||||||
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
|
|
||||||
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
|
|
||||||
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
|
|
||||||
if (existing.toSet() == incoming.toSet()) return
|
|
||||||
gradeComments.deleteByStudentIdAndLabId(sid, lid)
|
|
||||||
for ((text, tone) in incoming) {
|
|
||||||
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
|
|
||||||
val t = templates.findByText(text)
|
|
||||||
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
|
||||||
else { t.useCount += 1; t.tone = tone; templates.save(t) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
|
|
||||||
"positive", "положительный", "позитивный", "pos" -> "positive"
|
|
||||||
"negative", "негативный", "отрицательный", "neg" -> "negative"
|
|
||||||
else -> "neutral"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
fun getStudentAssignment(
|
||||||
|
studentId: UUID,
|
||||||
@Transactional(readOnly = true)
|
labId: UUID,
|
||||||
fun suggestComments(query: String): List<CommentOut> =
|
token: String,
|
||||||
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
): MeAssignmentResponse? {
|
||||||
.map { CommentOut(it.text, it.tone) }
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun listGrades(): List<GradeResponse> {
|
|
||||||
val byGrade = gradeComments.findAll()
|
|
||||||
.groupBy { it.studentId to it.labId }
|
|
||||||
return grades.findAllBy().map {
|
|
||||||
GradeResponse(
|
|
||||||
studentId = it.studentId.toString(),
|
|
||||||
labId = it.labId.toString(),
|
|
||||||
score = it.score,
|
|
||||||
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
|
||||||
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
||||||
return enrich(va, token)
|
return enrich(va, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
|
private fun enrich(
|
||||||
|
va: VariantAssignment,
|
||||||
|
token: String,
|
||||||
|
): MeAssignmentResponse? {
|
||||||
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
|
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
|
||||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
|
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
|
||||||
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
|
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package ru.ysu.lms.progress
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
class ProgressServiceApplication
|
class ProgressServiceApplication
|
||||||
|
|||||||
@@ -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 storage: SubmissionStorage,
|
||||||
private val academic: AcademicClient,
|
private val academic: AcademicClient,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun submit(
|
fun submit(
|
||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
@@ -26,30 +25,71 @@ class SubmissionService(
|
|||||||
files: List<MultipartFile>,
|
files: List<MultipartFile>,
|
||||||
token: String,
|
token: String,
|
||||||
): SubmissionDto {
|
): SubmissionDto {
|
||||||
if (files.isEmpty() || files.all { it.isEmpty }) {
|
val payload = validatedPayload(files)
|
||||||
throw BadRequestException("Приложите файлы или архив")
|
ensureDeadline(requireLab(labId, token))
|
||||||
}
|
val seq = nextSeq(studentId, labId)
|
||||||
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
|
||||||
?: throw NotFoundException("Лабораторная не найдена")
|
|
||||||
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
|
||||||
if (deadline != null && Instant.now().isAfter(deadline)) {
|
|
||||||
throw BadRequestException("Срок сдачи истёк")
|
|
||||||
}
|
|
||||||
val last = submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
|
||||||
val seq = (last?.seq ?: 0) + 1
|
|
||||||
val payload = files.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
|
|
||||||
val stored = storage.store(studentId, labId, seq, payload)
|
val stored = storage.store(studentId, labId, seq, payload)
|
||||||
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
|
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
|
||||||
return toDto(entity, stored.files)
|
return toDto(entity, stored.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validatedPayload(files: List<MultipartFile>): List<Pair<String, ByteArray>> {
|
||||||
|
if (files.isEmpty() || files.all { it.isEmpty }) {
|
||||||
|
throw BadRequestException("Приложите файлы или архив")
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
|
||||||
|
.onEach { (name, bytes) -> rejectProjectFile(name, bytes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rejectProjectFile(
|
||||||
|
name: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
) {
|
||||||
|
if (looksLikeProjectFile(name, bytes)) {
|
||||||
|
throw BadRequestException(
|
||||||
|
"Файл «$name» похож на файл проекта (XML), а не на исходник. " +
|
||||||
|
"Приложите файл .c/.h или zip с проектом.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireLab(
|
||||||
|
labId: UUID,
|
||||||
|
token: String,
|
||||||
|
): LabInfo =
|
||||||
|
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||||
|
?: throw NotFoundException("Лабораторная не найдена")
|
||||||
|
|
||||||
|
private fun ensureDeadline(lab: LabInfo) {
|
||||||
|
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
|
if (deadline != null && Instant.now().isAfter(deadline)) {
|
||||||
|
throw BadRequestException("Срок сдачи истёк")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextSeq(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): Int = (submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)?.seq ?: 0) + 1
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
|
fun mySubmissions(
|
||||||
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): List<SubmissionDto> =
|
||||||
|
submissions
|
||||||
|
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
||||||
|
.map { it to filesOf(it) }
|
||||||
.map { (e, fs) -> toDto(e, fs) }
|
.map { (e, fs) -> toDto(e, fs) }
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> {
|
fun download(
|
||||||
|
studentId: UUID,
|
||||||
|
submissionId: UUID,
|
||||||
|
callerStudentId: UUID?,
|
||||||
|
isAdmin: Boolean,
|
||||||
|
): Pair<ByteArray, String> {
|
||||||
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
|
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
|
||||||
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
||||||
throw ForbiddenException("Нет доступа к этой сдаче")
|
throw ForbiddenException("Нет доступа к этой сдаче")
|
||||||
@@ -70,10 +110,32 @@ class SubmissionService(
|
|||||||
return perStudent.values.map { toDto(it, filesOf(it)) }
|
return perStudent.values.map { toDto(it, filesOf(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filesOf(s: Submission): List<String> =
|
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
|
||||||
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
|
||||||
|
|
||||||
private fun toDto(s: Submission, files: List<String>): SubmissionDto =
|
private fun looksLikeProjectFile(
|
||||||
|
name: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): Boolean {
|
||||||
|
val ext = name.substringAfterLast('.', "").lowercase()
|
||||||
|
if (ext !in sourceExtensions) return false
|
||||||
|
val head =
|
||||||
|
String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
|
||||||
|
.removePrefix("\uFEFF")
|
||||||
|
.trimStart()
|
||||||
|
.lowercase()
|
||||||
|
return head.startsWith("<?xml") ||
|
||||||
|
head.startsWith("<solution") ||
|
||||||
|
head.startsWith("<project") ||
|
||||||
|
head.startsWith("<configurations")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||||
|
|
||||||
|
private fun toDto(
|
||||||
|
s: Submission,
|
||||||
|
files: List<String>,
|
||||||
|
): SubmissionDto =
|
||||||
SubmissionDto(
|
SubmissionDto(
|
||||||
id = s.id.toString(),
|
id = s.id.toString(),
|
||||||
seq = s.seq,
|
seq = s.seq,
|
||||||
|
|||||||
@@ -9,14 +9,29 @@ import java.util.UUID
|
|||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
data class StoredSubmission(val dir: Path, val files: List<String>)
|
data class StoredSubmission(
|
||||||
|
val dir: Path,
|
||||||
|
val files: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) {
|
class SubmissionStorage(
|
||||||
|
@Value("\${lms.submissions.dir:data/submissions}") root: String,
|
||||||
|
) {
|
||||||
private val rootDir: Path = Path.of(root)
|
private val rootDir: Path = Path.of(root)
|
||||||
|
|
||||||
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission {
|
fun store(
|
||||||
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize()
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
seq: Int,
|
||||||
|
files: List<Pair<String, ByteArray>>,
|
||||||
|
): StoredSubmission {
|
||||||
|
val dir =
|
||||||
|
rootDir
|
||||||
|
.resolve(studentId.toString())
|
||||||
|
.resolve(labId.toString())
|
||||||
|
.resolve(seq.toString())
|
||||||
|
.normalize()
|
||||||
require(dir.startsWith(rootDir)) { "path escape blocked" }
|
require(dir.startsWith(rootDir)) { "path escape blocked" }
|
||||||
Files.createDirectories(dir)
|
Files.createDirectories(dir)
|
||||||
val names = mutableListOf<String>()
|
val names = mutableListOf<String>()
|
||||||
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
|
|||||||
|
|
||||||
fun listFiles(storedDir: Path): List<String> =
|
fun listFiles(storedDir: Path): List<String> =
|
||||||
if (Files.isDirectory(storedDir)) {
|
if (Files.isDirectory(storedDir)) {
|
||||||
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
|
Files
|
||||||
|
.list(storedDir)
|
||||||
|
.map { it.fileName.toString() }
|
||||||
|
.sorted()
|
||||||
|
.toList()
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
|
fun dirFor(
|
||||||
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
seq: Int,
|
||||||
|
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||||
|
|
||||||
private fun sanitize(name: String): String {
|
private fun sanitize(name: String): String {
|
||||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||||
|
|||||||
@@ -21,4 +21,8 @@ class GradePublication(
|
|||||||
data class GradePublicationKey(
|
data class GradePublicationKey(
|
||||||
val groupId: UUID = UUID(0, 0),
|
val groupId: UUID = UUID(0, 0),
|
||||||
val labId: UUID = UUID(0, 0),
|
val labId: UUID = UUID(0, 0),
|
||||||
) : Serializable
|
) : Serializable {
|
||||||
|
companion object {
|
||||||
|
private const val serialVersionUID: Long = 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,4 +23,8 @@ class LabGrade(
|
|||||||
data class LabGradeKey(
|
data class LabGradeKey(
|
||||||
val studentId: UUID = UUID(0, 0),
|
val studentId: UUID = UUID(0, 0),
|
||||||
val labId: UUID = UUID(0, 0),
|
val labId: UUID = UUID(0, 0),
|
||||||
) : Serializable
|
) : Serializable {
|
||||||
|
companion object {
|
||||||
|
private const val serialVersionUID: Long = 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
||||||
fun findByText(text: String): CommentTemplate?
|
fun findByText(text: String): CommentTemplate?
|
||||||
|
|
||||||
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
||||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
|
fun findByStudentIdAndLabId(
|
||||||
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): List<GradeComment>
|
||||||
|
|
||||||
|
fun deleteByStudentIdAndLabId(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
)
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<GradeComment>
|
fun findByStudentId(studentId: UUID): List<GradeComment>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
||||||
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
|
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
|
||||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
|
|
||||||
|
fun findByStudentIdAndLabId(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): DefenseSession?
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
||||||
|
|
||||||
fun findByStatus(status: String): List<DefenseSession>
|
fun findByStatus(status: String): List<DefenseSession>
|
||||||
|
|
||||||
fun findByCode(code: String): DefenseSession?
|
fun findByCode(code: String): DefenseSession?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
|
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
|
||||||
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
|
fun findByLabIdAndGroupId(
|
||||||
|
labId: UUID,
|
||||||
|
groupId: UUID,
|
||||||
|
): GradePublication?
|
||||||
|
|
||||||
fun findByLabId(labId: UUID): List<GradePublication>
|
fun findByLabId(labId: UUID): List<GradePublication>
|
||||||
|
|
||||||
fun findByGroupId(groupId: UUID): List<GradePublication>
|
fun findByGroupId(groupId: UUID): List<GradePublication>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||||
fun findAllBy(): List<LabGrade>
|
fun findAllBy(): List<LabGrade>
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<LabGrade>
|
fun findByStudentId(studentId: UUID): List<LabGrade>
|
||||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
|
|
||||||
|
fun findByStudentIdAndLabId(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): LabGrade?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
|
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
|
||||||
fun findByLabId(labId: UUID): List<StudentLabLevel>
|
fun findByLabId(labId: UUID): List<StudentLabLevel>
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
|
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
|
||||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
|
|
||||||
|
fun findByStudentIdAndLabId(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): StudentLabLevel?
|
||||||
|
|
||||||
fun deleteByLabId(labId: UUID)
|
fun deleteByLabId(labId: UUID)
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,16 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
||||||
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
|
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
|
||||||
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
|
|
||||||
|
fun findByStudentIdAndLabIdOrderBySeqDesc(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): List<Submission>
|
||||||
|
|
||||||
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
||||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
|
|
||||||
|
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): Submission?
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,13 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
||||||
fun findByStudentId(studentId: UUID): List<VariantAssignment>
|
fun findByStudentId(studentId: UUID): List<VariantAssignment>
|
||||||
|
|
||||||
fun findByLabId(labId: UUID): List<VariantAssignment>
|
fun findByLabId(labId: UUID): List<VariantAssignment>
|
||||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
|
|
||||||
|
fun findByStudentIdAndLabId(
|
||||||
|
studentId: UUID,
|
||||||
|
labId: UUID,
|
||||||
|
): VariantAssignment?
|
||||||
|
|
||||||
fun deleteByLabId(labId: UUID)
|
fun deleteByLabId(labId: UUID)
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
|||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.core.GrantedAuthority
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||||
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
class SecurityConfig {
|
class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||||
|
|
||||||
@@ -29,8 +27,11 @@ class SecurityConfig {
|
|||||||
val converter = JwtAuthenticationConverter()
|
val converter = JwtAuthenticationConverter()
|
||||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||||
val role = jwt.getClaimAsString("role")
|
val role = jwt.getClaimAsString("role")
|
||||||
if (role.isNullOrBlank()) emptyList()
|
if (role.isNullOrBlank()) {
|
||||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return converter
|
return converter
|
||||||
}
|
}
|
||||||
@@ -44,8 +45,7 @@ class SecurityConfig {
|
|||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}.oauth2ResourceServer { oauth ->
|
||||||
.oauth2ResourceServer { oauth ->
|
|
||||||
oauth.jwt { jwt ->
|
oauth.jwt { jwt ->
|
||||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||||
|
|
||||||
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
|
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
|
||||||
fun formatMsk(value: String?): String? =
|
fun formatMsk(value: String?): String? = toZoned(value)?.format(FORMAT)
|
||||||
toZoned(value)?.format(FORMAT)
|
|
||||||
|
|
||||||
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
|
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
|
||||||
fun localInput(value: String?): String? =
|
fun localInput(value: String?): String? = toZoned(value)?.format(INPUT_FORMAT) ?: ""
|
||||||
toZoned(value)?.format(INPUT_FORMAT) ?: ""
|
|
||||||
|
|
||||||
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
|
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
|
||||||
fun toUtcIso(localValue: String?): String? =
|
fun toUtcIso(localValue: String?): String? =
|
||||||
localValue?.takeIf { it.isNotBlank() }?.let { s ->
|
localValue?.takeIf { it.isNotBlank() }?.let { s ->
|
||||||
try {
|
try {
|
||||||
LocalDateTime.parse(s, INPUT_FORMAT)
|
LocalDateTime
|
||||||
|
.parse(s, INPUT_FORMAT)
|
||||||
.atZone(MSK)
|
.atZone(MSK)
|
||||||
.toInstant()
|
.toInstant()
|
||||||
.toString()
|
.toString()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.assignDefense
|
||||||
|
import ru.ysu.lms.web.api.issueDefenseCode
|
||||||
|
import ru.ysu.lms.web.api.resetDefense
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminDefenseController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@PostMapping("/labs/{labId}/defense/assign")
|
||||||
|
fun assignDefense(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@ModelAttribute form: DefenseForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||||
|
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||||
|
redirectToGroup(form.groupId, labId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectToGroup(form.groupId, labId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Назначение/правка индивидуального задания со страницы группы. */
|
||||||
|
@PostMapping("/defenses/save")
|
||||||
|
fun saveDefense(
|
||||||
|
@ModelAttribute form: DefenseForm,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||||
|
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||||
|
redirectToGroup(form.groupId, labId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectToGroup(form.groupId, labId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/defense/import")
|
||||||
|
fun importDefenses(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||||
|
|
||||||
|
/** Импорт CSV со страницы группы: лабораторная выбирается в форме или берётся из строки. */
|
||||||
|
|
||||||
|
@PostMapping("/defenses/import")
|
||||||
|
fun importDefensesGeneric(
|
||||||
|
@RequestParam(required = false) labId: String?,
|
||||||
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||||
|
|
||||||
|
@PostMapping("/defenses/{id}/issue-code")
|
||||||
|
fun issueDefenseCode(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val code = api.issueDefenseCode(token(session), id)
|
||||||
|
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/defenses/{id}/reset")
|
||||||
|
fun resetDefenseSession(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.resetDefense(token(session), id)
|
||||||
|
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AssignVariantPayload
|
||||||
|
import ru.ysu.lms.web.api.DistributeRequestPayload
|
||||||
|
import ru.ysu.lms.web.api.LevelAssignPayload
|
||||||
|
import ru.ysu.lms.web.api.assignVariant
|
||||||
|
import ru.ysu.lms.web.api.distribute
|
||||||
|
import ru.ysu.lms.web.api.listLabs
|
||||||
|
import ru.ysu.lms.web.api.setLevel
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminDistributeController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@PostMapping("/groups/{gid}/set-level")
|
||||||
|
fun setStudentLevel(
|
||||||
|
@PathVariable gid: String,
|
||||||
|
@ModelAttribute form: StudentLevelForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{gid}/pin-variant")
|
||||||
|
fun pinStudentVariant(
|
||||||
|
@PathVariable gid: String,
|
||||||
|
@ModelAttribute form: PinVariantForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
|
||||||
|
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{gid}/distribute")
|
||||||
|
fun distributeForGroup(
|
||||||
|
@PathVariable gid: String,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
@RequestParam(defaultValue = "false") force: Boolean,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
api.distribute(
|
||||||
|
token(session),
|
||||||
|
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
|
||||||
|
@PostMapping("/groups/{gid}/distribute-all")
|
||||||
|
fun distributeAllForGroup(
|
||||||
|
@PathVariable gid: String,
|
||||||
|
@RequestParam(defaultValue = "false") force: Boolean,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val t = token(session)
|
||||||
|
val labs = api.listLabs(t).filter { it.status == "published" }
|
||||||
|
if (labs.isEmpty()) {
|
||||||
|
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
|
||||||
|
return "redirect:/admin/groups/$gid"
|
||||||
|
}
|
||||||
|
val result =
|
||||||
|
api.distribute(
|
||||||
|
t,
|
||||||
|
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$gid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Массовая установка уровня выделенным студентам. */
|
||||||
|
@PostMapping("/labs/{labId}/batch-level")
|
||||||
|
fun batchLevel(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam("studentIds") studentIds: List<String>,
|
||||||
|
@RequestParam labLevelId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val t = token(session)
|
||||||
|
studentIds.filter { it.isNotBlank() }.forEach { sid ->
|
||||||
|
api.setLevel(t, LevelAssignPayload(sid, labId, labLevelId))
|
||||||
|
}
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень обновлён у ${studentIds.size} студент(ов)")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Массовое закрепление конкретного варианта выделенным студентам. */
|
||||||
|
@PostMapping("/labs/{labId}/batch-variant")
|
||||||
|
fun batchVariant(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam("studentIds") studentIds: List<String>,
|
||||||
|
@RequestParam variantId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val t = token(session)
|
||||||
|
studentIds.filter { it.isNotBlank() }.forEach { sid ->
|
||||||
|
api.assignVariant(t, AssignVariantPayload(sid, labId, variantId))
|
||||||
|
}
|
||||||
|
redirect.addFlashAttribute("notice", "Вариант закреплён у ${studentIds.size} студент(ов)")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{id}/distribute")
|
||||||
|
fun distribute(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam(defaultValue = "false") force: Boolean,
|
||||||
|
@RequestParam(required = false) groupIds: List<String>?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
api.distribute(
|
||||||
|
token(session),
|
||||||
|
DistributeRequestPayload(
|
||||||
|
labId = id,
|
||||||
|
force = force,
|
||||||
|
groupIds = groupIds.orEmpty().filter { it.isNotBlank() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
Normal file
92
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
// Формы админ-контроллеров: связываются через @ModelAttribute.
|
||||||
|
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @RequestParam).
|
||||||
|
|
||||||
|
data class CreateStudentForm(
|
||||||
|
val studentNo: String = "",
|
||||||
|
val lastName: String = "",
|
||||||
|
val firstName: String? = null,
|
||||||
|
val middleName: String? = null,
|
||||||
|
val basis: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StudentLevelForm(
|
||||||
|
val studentId: String = "",
|
||||||
|
val labId: String = "",
|
||||||
|
val labLevelId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PinVariantForm(
|
||||||
|
val studentId: String = "",
|
||||||
|
val labId: String = "",
|
||||||
|
val variantId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DefenseForm(
|
||||||
|
val studentId: String = "",
|
||||||
|
val assignmentMd: String = "",
|
||||||
|
val groupId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateLabForm(
|
||||||
|
val number: Int = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val defenseAt: String? = null,
|
||||||
|
val pointsMax: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val descriptionMd: String = "",
|
||||||
|
val levels: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SaveLevelForm(
|
||||||
|
val levelId: String? = null,
|
||||||
|
val code: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SaveVariantForm(
|
||||||
|
val variantId: String? = null,
|
||||||
|
val labLevelId: String = "",
|
||||||
|
val number: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val statementMd: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EditLabForm(
|
||||||
|
val title: String = "",
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val defenseAt: String? = null,
|
||||||
|
val pointsMax: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val descriptionMd: String = "",
|
||||||
|
val action: String = "save",
|
||||||
|
val levelIds: List<String>? = null,
|
||||||
|
val levelTitles: List<String>? = null,
|
||||||
|
val variantIds: List<String>? = null,
|
||||||
|
val variantLevelIds: List<String>? = null,
|
||||||
|
val variantNumbers: List<String>? = null,
|
||||||
|
val variantTitles: List<String>? = null,
|
||||||
|
val variantStatements: List<String>? = null,
|
||||||
|
val newLevelCode: String? = null,
|
||||||
|
val newLevelTitle: String? = null,
|
||||||
|
val addLevelId: String? = null,
|
||||||
|
val addNumber: String? = null,
|
||||||
|
val addTitle: String? = null,
|
||||||
|
val addStatementMd: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SetGradeForm(
|
||||||
|
val studentId: String = "",
|
||||||
|
val labId: String = "",
|
||||||
|
val score: String = "",
|
||||||
|
val commentsJson: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SubmissionDownloadForm(
|
||||||
|
val group: String? = null,
|
||||||
|
val no: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val attempt: String? = null,
|
||||||
|
)
|
||||||
311
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
Normal file
311
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.GradeSetPayload
|
||||||
|
import ru.ysu.lms.web.api.GroupDetailDto
|
||||||
|
import ru.ysu.lms.web.api.LabSummaryDto
|
||||||
|
import ru.ysu.lms.web.api.StudentCandidateDto
|
||||||
|
import ru.ysu.lms.web.api.createGroup
|
||||||
|
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
||||||
|
import ru.ysu.lms.web.api.getLabAssignments
|
||||||
|
import ru.ysu.lms.web.api.groupCandidates
|
||||||
|
import ru.ysu.lms.web.api.groupDetail
|
||||||
|
import ru.ysu.lms.web.api.groupPublications
|
||||||
|
import ru.ysu.lms.web.api.importGroup
|
||||||
|
import ru.ysu.lms.web.api.labDefenses
|
||||||
|
import ru.ysu.lms.web.api.labSubmissions
|
||||||
|
import ru.ysu.lms.web.api.listGrades
|
||||||
|
import ru.ysu.lms.web.api.listGroups
|
||||||
|
import ru.ysu.lms.web.api.listLabs
|
||||||
|
import ru.ysu.lms.web.api.publishGrades
|
||||||
|
import ru.ysu.lms.web.api.setGrade
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminGroupController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@GetMapping
|
||||||
|
fun dashboard(
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val t = token(session)
|
||||||
|
val groups = api.listGroups(t)
|
||||||
|
val labs = api.listLabs(t)
|
||||||
|
val grades = api.listGrades(t)
|
||||||
|
model.addAttribute("groupCount", groups.size)
|
||||||
|
model.addAttribute("studentCount", groups.sumOf { it.studentCount })
|
||||||
|
model.addAttribute("groupCount", groups.size)
|
||||||
|
model.addAttribute("labCount", labs.size)
|
||||||
|
val gradedByStudent = grades.groupBy { it.studentId }
|
||||||
|
val averages =
|
||||||
|
gradedByStudent.values.mapNotNull { rows ->
|
||||||
|
rows
|
||||||
|
.mapNotNull { g -> g.score.toBigDecimalOrNull() }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { nums -> nums.map { it.toDouble() }.average() }
|
||||||
|
}
|
||||||
|
model.addAttribute("avgGrade", if (averages.isEmpty()) null else averages.average())
|
||||||
|
model.addAttribute("publishedLabs", labs.count { it.status == "published" })
|
||||||
|
model.addAttribute("labs", labs)
|
||||||
|
model.addAttribute("groups", groups)
|
||||||
|
return "admin/dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups")
|
||||||
|
fun groups(
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
model.addAttribute("groups", api.listGroups(token(session)))
|
||||||
|
return "admin/groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups")
|
||||||
|
fun createGroup(
|
||||||
|
@RequestParam name: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.createGroup(token(session), name.trim())
|
||||||
|
redirect.addFlashAttribute("notice", "Группа создана")
|
||||||
|
"redirect:/admin/groups"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/import")
|
||||||
|
fun importGroup(
|
||||||
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
@RequestParam(name = "name", required = false) name: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
if (file.isEmpty) {
|
||||||
|
redirect.addFlashAttribute("error", "Выберите файл со списком группы")
|
||||||
|
return "redirect:/admin/groups"
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val result =
|
||||||
|
api.importGroup(
|
||||||
|
token(session),
|
||||||
|
name,
|
||||||
|
file.originalFilename ?: "group.xlsx",
|
||||||
|
file.bytes,
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("importResult", result)
|
||||||
|
"redirect:/admin/groups/${result.groupId}"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{id}")
|
||||||
|
fun groupDetail(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val t = token(session)
|
||||||
|
val (group, labs) = loadGroupAndLabs(t, id)
|
||||||
|
val grades = api.listGrades(t)
|
||||||
|
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
|
||||||
|
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
|
||||||
|
val publishedLabs = java.util.HashSet(api.groupPublications(t, id))
|
||||||
|
val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } }
|
||||||
|
val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } }
|
||||||
|
val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } }
|
||||||
|
|
||||||
|
val rows =
|
||||||
|
group.students.map { s ->
|
||||||
|
val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }
|
||||||
|
val cells =
|
||||||
|
labs.map { lab ->
|
||||||
|
val va = assignmentsBy.getValue(lab)[s.id]
|
||||||
|
val sub = submissionsBy.getValue(lab)[s.id]
|
||||||
|
val def = defensesBy.getValue(lab)[s.id]
|
||||||
|
GroupLabCell(
|
||||||
|
labId = lab.id,
|
||||||
|
labNumber = lab.number,
|
||||||
|
labTitle = lab.title,
|
||||||
|
variantNumber = va?.variantNumber,
|
||||||
|
variantManual = va?.manual == true,
|
||||||
|
score = gradeBy["${s.id}|${lab.id}"],
|
||||||
|
published = publishedLabs.contains(lab.id),
|
||||||
|
submissionSeq = sub?.seq,
|
||||||
|
submissionId = sub?.id,
|
||||||
|
defenseStatus = def?.status,
|
||||||
|
defenseCode = def?.code,
|
||||||
|
defenseSecondsLeft = def?.secondsLeft,
|
||||||
|
defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupStudentRow(
|
||||||
|
studentId = s.id,
|
||||||
|
fullName = s.fullName,
|
||||||
|
studentNo = s.studentNo,
|
||||||
|
basis = s.basis,
|
||||||
|
mustChangePassword = s.mustChangePassword,
|
||||||
|
cells = cells,
|
||||||
|
variantsSummary =
|
||||||
|
cells
|
||||||
|
.filter { it.variantNumber != null }
|
||||||
|
.joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" },
|
||||||
|
commentsJson =
|
||||||
|
mapper.writeValueAsString(
|
||||||
|
comments.map { cs ->
|
||||||
|
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val defenseRows =
|
||||||
|
buildDefenseRows(t, group, labs)
|
||||||
|
|
||||||
|
val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())
|
||||||
|
fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, defenseRows, publishedLabs))
|
||||||
|
return "admin/group"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadGroupAndLabs(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
|
||||||
|
|
||||||
|
/** Строки для блока «Защиты»: сессии защиты по всем лабораторным группы. */
|
||||||
|
private fun buildDefenseRows(
|
||||||
|
token: String,
|
||||||
|
group: GroupDetailDto,
|
||||||
|
labs: List<LabSummaryDto>,
|
||||||
|
): List<GroupDefenseRow> =
|
||||||
|
labs
|
||||||
|
.flatMap { lab ->
|
||||||
|
val byStudent = api.labDefenses(token, lab.id).associateBy { it.studentId }
|
||||||
|
group.students.mapNotNull { s ->
|
||||||
|
val d = byStudent[s.id] ?: return@mapNotNull null
|
||||||
|
GroupDefenseRow(
|
||||||
|
sessionId = d.id,
|
||||||
|
studentId = s.id,
|
||||||
|
fullName = s.fullName,
|
||||||
|
studentNo = s.studentNo,
|
||||||
|
labId = lab.id,
|
||||||
|
labNumber = lab.number,
|
||||||
|
status = d.status,
|
||||||
|
code = d.code?.takeIf { d.status == "waiting" },
|
||||||
|
assignmentMd = d.assignmentMd,
|
||||||
|
secondsLeft = d.secondsLeft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber }))
|
||||||
|
|
||||||
|
private data class GroupViewModel(
|
||||||
|
val group: GroupDetailDto,
|
||||||
|
val labs: List<LabSummaryDto>,
|
||||||
|
val candidates: List<StudentCandidateDto>,
|
||||||
|
val rows: List<GroupStudentRow>,
|
||||||
|
val defenseRows: List<GroupDefenseRow>,
|
||||||
|
val publishedLabs: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fillGroupModel(
|
||||||
|
model: Model,
|
||||||
|
view: GroupViewModel,
|
||||||
|
) {
|
||||||
|
model.addAttribute("group", view.group)
|
||||||
|
model.addAttribute("labs", view.labs)
|
||||||
|
model.addAttribute("candidates", view.candidates)
|
||||||
|
model.addAttribute("studentGrades", view.rows)
|
||||||
|
model.addAttribute("defenseRows", view.defenseRows)
|
||||||
|
model.addAttribute("publishedLabIds", view.publishedLabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{id}/grades")
|
||||||
|
fun setGrade(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@ModelAttribute form: SetGradeForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val comments = parseComments(form.commentsJson ?: "[]")
|
||||||
|
api.setGrade(token(session), GradeSetPayload(form.studentId, form.labId, form.score, comments))
|
||||||
|
redirect.addFlashAttribute("notice", "Оценка сохранена")
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{id}/submissions.zip")
|
||||||
|
fun downloadGroupSubmissions(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam(required = false) labId: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
): ResponseEntity<ByteArray> {
|
||||||
|
val t = token(session)
|
||||||
|
val group = api.groupDetail(t, id)
|
||||||
|
val studentById = group.students.associateBy { it.id }
|
||||||
|
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
||||||
|
val bytes = buildGroupArchive(t, group.name, studentById, labs)
|
||||||
|
return zipAttachment("solutions-${group.name}.zip", bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{id}/publish")
|
||||||
|
fun publishGrades(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam labId: String,
|
||||||
|
@RequestParam published: Boolean,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.publishGrades(token(session), labId, id, published)
|
||||||
|
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы")
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{id}/download")
|
||||||
|
fun downloadSubmissionAsAdmin(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@ModelAttribute form: SubmissionDownloadForm,
|
||||||
|
session: HttpSession,
|
||||||
|
): ResponseEntity<ByteArray> {
|
||||||
|
val file = api.downloadSubmissionAsAdmin(token(session), id)
|
||||||
|
val filename = submissionFilename(form.group, form.no, form.name, form.attempt) ?: file.filename
|
||||||
|
val encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8)
|
||||||
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.body(file.bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
525
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt
Normal file
525
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AssignVariantPayload
|
||||||
|
import ru.ysu.lms.web.api.LabCreatePayload
|
||||||
|
import ru.ysu.lms.web.api.LabDetailDto
|
||||||
|
import ru.ysu.lms.web.api.LabLevelDetailDto
|
||||||
|
import ru.ysu.lms.web.api.LabUpdatePayload
|
||||||
|
import ru.ysu.lms.web.api.LevelAssignPayload
|
||||||
|
import ru.ysu.lms.web.api.LevelAssignmentResultDto
|
||||||
|
import ru.ysu.lms.web.api.LevelPayload
|
||||||
|
import ru.ysu.lms.web.api.LevelSavePayload
|
||||||
|
import ru.ysu.lms.web.api.SubmissionDto
|
||||||
|
import ru.ysu.lms.web.api.VariantAssignmentResultDto
|
||||||
|
import ru.ysu.lms.web.api.VariantSavePayload
|
||||||
|
import ru.ysu.lms.web.api.addLevel
|
||||||
|
import ru.ysu.lms.web.api.assignVariant
|
||||||
|
import ru.ysu.lms.web.api.createLab
|
||||||
|
import ru.ysu.lms.web.api.deleteLab
|
||||||
|
import ru.ysu.lms.web.api.deleteLevel
|
||||||
|
import ru.ysu.lms.web.api.deleteVariant
|
||||||
|
import ru.ysu.lms.web.api.getLabAssignments
|
||||||
|
import ru.ysu.lms.web.api.getLevels
|
||||||
|
import ru.ysu.lms.web.api.groupDetail
|
||||||
|
import ru.ysu.lms.web.api.importLab
|
||||||
|
import ru.ysu.lms.web.api.labDetail
|
||||||
|
import ru.ysu.lms.web.api.labSubmissions
|
||||||
|
import ru.ysu.lms.web.api.listGroups
|
||||||
|
import ru.ysu.lms.web.api.listLabs
|
||||||
|
import ru.ysu.lms.web.api.renameLevel
|
||||||
|
import ru.ysu.lms.web.api.saveVariant
|
||||||
|
import ru.ysu.lms.web.api.setLevel
|
||||||
|
import ru.ysu.lms.web.api.toggleLabStatus
|
||||||
|
import ru.ysu.lms.web.api.updateLab
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminLabController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@GetMapping("/labs")
|
||||||
|
fun labs(
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
model.addAttribute("labs", api.listLabs(token(session)))
|
||||||
|
return "admin/labs"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/import")
|
||||||
|
fun importLab(
|
||||||
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
if (file.isEmpty) {
|
||||||
|
redirect.addFlashAttribute("error", "Выберите файл")
|
||||||
|
return "redirect:/admin/labs"
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val result =
|
||||||
|
api.importLab(
|
||||||
|
token(session),
|
||||||
|
file.originalFilename ?: "lab.zip",
|
||||||
|
file.bytes,
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute(
|
||||||
|
"notice",
|
||||||
|
"Лабораторная ${result.number}: «${result.title}» импортирована (${result.variantCount} вариантов)",
|
||||||
|
)
|
||||||
|
"redirect:/admin/labs"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{id}/toggle-status")
|
||||||
|
fun toggleStatus(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.toggleLabStatus(token(session), id)
|
||||||
|
redirect.addFlashAttribute("notice", "Статус лабораторной изменён")
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{id}/delete")
|
||||||
|
fun deleteLab(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.deleteLab(token(session), id)
|
||||||
|
redirect.addFlashAttribute("notice", "Лабораторная удалена")
|
||||||
|
"redirect:/admin/labs"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/labs/{id}")
|
||||||
|
fun labDetail(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val t = token(session)
|
||||||
|
val lab = api.labDetail(t, id)
|
||||||
|
val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId }
|
||||||
|
val assignments = api.getLabAssignments(t, id)
|
||||||
|
val assignedCount = assignments.size
|
||||||
|
val levelByStudent = api.getLevels(t, id).associateBy { it.studentId }
|
||||||
|
val assignmentByStudent = assignments.associateBy { it.studentId }
|
||||||
|
val variantUsage = java.util.HashMap<String, Int>()
|
||||||
|
assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
|
||||||
|
val allVariants = lab.levels.flatMap { it.variants }
|
||||||
|
val totalVariants = allVariants.size
|
||||||
|
val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 }
|
||||||
|
val defaultLevel = lab.levels.firstOrNull()
|
||||||
|
|
||||||
|
val assignmentGroups =
|
||||||
|
buildAssignmentGroups(
|
||||||
|
t,
|
||||||
|
LabAssignmentData(lab, levelByStudent, assignmentByStudent, submissionsByStudent, defaultLevel),
|
||||||
|
)
|
||||||
|
|
||||||
|
model.addAttribute("lab", lab)
|
||||||
|
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
||||||
|
model.addAttribute(
|
||||||
|
"deadlineMsk",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.formatMsk(lab.deadlineAt),
|
||||||
|
)
|
||||||
|
model.addAttribute(
|
||||||
|
"defenseMsk",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.formatMsk(lab.defenseAt),
|
||||||
|
)
|
||||||
|
model.addAttribute(
|
||||||
|
"deadlineLocal",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.localInput(lab.deadlineAt),
|
||||||
|
)
|
||||||
|
model.addAttribute(
|
||||||
|
"defenseLocal",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.localInput(lab.defenseAt),
|
||||||
|
)
|
||||||
|
model.addAttribute("assignedCount", assignedCount)
|
||||||
|
model.addAttribute("submissionCount", submissionsByStudent.size)
|
||||||
|
model.addAttribute("assignmentGroups", assignmentGroups)
|
||||||
|
model.addAttribute("groups", api.listGroups(t))
|
||||||
|
model.addAttribute("variantUsage", variantUsage)
|
||||||
|
model.addAttribute("totalVariants", totalVariants)
|
||||||
|
model.addAttribute("occupiedVariants", occupiedVariants)
|
||||||
|
model.addAttribute("freeVariants", totalVariants - occupiedVariants)
|
||||||
|
return "admin/lab"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LabAssignmentData(
|
||||||
|
val lab: LabDetailDto,
|
||||||
|
val levelByStudent: Map<String, LevelAssignmentResultDto>,
|
||||||
|
val assignmentByStudent: Map<String, VariantAssignmentResultDto>,
|
||||||
|
val submissionsByStudent: Map<String, SubmissionDto>,
|
||||||
|
val defaultLevel: LabLevelDetailDto?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Строки «группа → студенты» со их уровнем, вариантом и последней сдачей. */
|
||||||
|
private fun buildAssignmentGroups(
|
||||||
|
token: String,
|
||||||
|
data: LabAssignmentData,
|
||||||
|
): List<AdminLabGroup> =
|
||||||
|
api.listGroups(token).map { g ->
|
||||||
|
val detail = api.groupDetail(token, g.id)
|
||||||
|
AdminLabGroup(
|
||||||
|
groupName = g.name,
|
||||||
|
students =
|
||||||
|
detail.students.map { s ->
|
||||||
|
val levelState = data.levelByStudent[s.id]
|
||||||
|
val level = data.lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: data.defaultLevel
|
||||||
|
val va = data.assignmentByStudent[s.id]
|
||||||
|
AdminLabStudent(
|
||||||
|
studentId = s.id,
|
||||||
|
fullName = s.fullName,
|
||||||
|
studentNo = s.studentNo,
|
||||||
|
levelId = level?.id ?: "",
|
||||||
|
levelTitle = level?.title ?: "—",
|
||||||
|
levelChanged = levelState?.manual == true,
|
||||||
|
variantId = va?.variantId,
|
||||||
|
variantNumber = va?.variantNumber,
|
||||||
|
source = if (va?.manual == true) "вручную" else "авто",
|
||||||
|
submissionId = data.submissionsByStudent[s.id]?.id,
|
||||||
|
submissionSeq = data.submissionsByStudent[s.id]?.seq,
|
||||||
|
submissionFiles = data.submissionsByStudent[s.id]?.files ?: emptyList(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/assign")
|
||||||
|
fun assignVariant(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam studentId: String,
|
||||||
|
@RequestParam variantId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId))
|
||||||
|
redirect.addFlashAttribute("notice", "Назначен вариант ${result.variantNumber}")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/create")
|
||||||
|
fun createLab(
|
||||||
|
@ModelAttribute form: CreateLabForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
// levels: строки "код;название" по одной на строку
|
||||||
|
val levelList =
|
||||||
|
form.levels
|
||||||
|
.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.mapIndexed { idx, line ->
|
||||||
|
val parts = line.split(";", limit = 2)
|
||||||
|
LevelPayload(
|
||||||
|
code = parts[0].trim(),
|
||||||
|
title = parts.getOrNull(1)?.trim().takeUnless { it.isNullOrBlank() } ?: parts[0].trim(),
|
||||||
|
position = idx + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val created =
|
||||||
|
api.createLab(
|
||||||
|
token(session),
|
||||||
|
LabCreatePayload(
|
||||||
|
number = form.number,
|
||||||
|
title = form.title.trim(),
|
||||||
|
deadlineAt =
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.toUtcIso(form.deadlineAt),
|
||||||
|
defenseAt =
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.toUtcIso(form.defenseAt),
|
||||||
|
pointsMax = java.math.BigDecimal(form.pointsMax),
|
||||||
|
status = form.status,
|
||||||
|
descriptionMd = form.descriptionMd,
|
||||||
|
levels = levelList,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана")
|
||||||
|
"redirect:/admin/labs/${created.id}"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/levels/save")
|
||||||
|
fun saveLevel(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@ModelAttribute form: SaveLevelForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
if (form.levelId.isNullOrBlank()) {
|
||||||
|
api.addLevel(token(session), labId, LevelSavePayload(form.code, form.title))
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень добавлен")
|
||||||
|
} else {
|
||||||
|
api.renameLevel(token(session), labId, form.levelId, form.title)
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень обновлён")
|
||||||
|
}
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/levels/{levelId}/delete")
|
||||||
|
fun deleteLevel(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@PathVariable levelId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.deleteLevel(token(session), labId, levelId)
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень удалён")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/labs/{id}/edit")
|
||||||
|
/** Быстрые действия «удалить уровень/вариант» на форме лабы; null — обычное сохранение. */
|
||||||
|
private fun handleEditDeleteAction(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
action: String,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String? =
|
||||||
|
when {
|
||||||
|
action.startsWith("delete-level-") -> {
|
||||||
|
api.deleteLevel(token, id, action.removePrefix("delete-level-"))
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень удалён")
|
||||||
|
"redirect:/admin/labs/$id/edit"
|
||||||
|
}
|
||||||
|
action.startsWith("delete-variant-") -> {
|
||||||
|
api.deleteVariant(token, id, action.removePrefix("delete-variant-"))
|
||||||
|
redirect.addFlashAttribute("notice", "Вариант удалён")
|
||||||
|
"redirect:/admin/labs/$id/edit"
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLevelRenames(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
levelIds: List<String>?,
|
||||||
|
levelTitles: List<String>?,
|
||||||
|
) {
|
||||||
|
levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) ->
|
||||||
|
if (levelId.isNotBlank() && newTitle.isNotBlank()) {
|
||||||
|
api.renameLevel(token, id, levelId, newTitle.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
private fun applyVariantSaves(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
variantIds: List<String>?,
|
||||||
|
variantNumbers: List<String>?,
|
||||||
|
variantLevelIds: List<String>?,
|
||||||
|
variantTitles: List<String>?,
|
||||||
|
variantStatements: List<String>?,
|
||||||
|
) {
|
||||||
|
variantIds.orEmpty().forEachIndexed { idx, variantId ->
|
||||||
|
val number =
|
||||||
|
variantNumbers
|
||||||
|
.orEmpty()
|
||||||
|
.getOrNull(idx)
|
||||||
|
.orEmpty()
|
||||||
|
.trim()
|
||||||
|
if (variantId.isNotBlank() && number.isNotBlank()) {
|
||||||
|
api.saveVariant(
|
||||||
|
token,
|
||||||
|
id,
|
||||||
|
variantId,
|
||||||
|
VariantSavePayload(
|
||||||
|
variantLevelIds.orEmpty().getOrNull(idx).orEmpty(),
|
||||||
|
number,
|
||||||
|
variantTitles
|
||||||
|
.orEmpty()
|
||||||
|
.getOrNull(idx)
|
||||||
|
.orEmpty()
|
||||||
|
.trim(),
|
||||||
|
variantStatements.orEmpty().getOrNull(idx).orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editLabForm(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val lab = api.labDetail(token(session), id)
|
||||||
|
// datetime-local требует формат yyyy-MM-dd'T'HH:mm
|
||||||
|
model.addAttribute("lab", lab)
|
||||||
|
model.addAttribute(
|
||||||
|
"deadlineLocal",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.localInput(lab.deadlineAt),
|
||||||
|
)
|
||||||
|
model.addAttribute(
|
||||||
|
"defenseLocal",
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.localInput(lab.defenseAt),
|
||||||
|
)
|
||||||
|
return "admin/lab-edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */
|
||||||
|
@PostMapping("/labs/{id}/edit")
|
||||||
|
fun editLab(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@ModelAttribute form: EditLabForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val t = token(session)
|
||||||
|
return try {
|
||||||
|
handleEditDeleteAction(t, id, form.action, redirect)?.let { return it }
|
||||||
|
|
||||||
|
api.updateLab(
|
||||||
|
t,
|
||||||
|
id,
|
||||||
|
LabUpdatePayload(
|
||||||
|
form.title,
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.toUtcIso(form.deadlineAt),
|
||||||
|
ru.ysu.lms.web.DateUtils
|
||||||
|
.toUtcIso(form.defenseAt),
|
||||||
|
form.pointsMax,
|
||||||
|
form.status,
|
||||||
|
form.descriptionMd,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
applyLevelRenames(t, id, form.levelIds, form.levelTitles)
|
||||||
|
applyVariantSaves(t, id, form.variantIds, form.variantNumbers, form.variantLevelIds, form.variantTitles, form.variantStatements)
|
||||||
|
when {
|
||||||
|
form.action == "add-level" && !form.newLevelCode.isNullOrBlank() && !form.newLevelTitle.isNullOrBlank() -> {
|
||||||
|
api.addLevel(t, id, LevelSavePayload(form.newLevelCode.trim(), form.newLevelTitle.trim()))
|
||||||
|
redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен")
|
||||||
|
}
|
||||||
|
form.action == "add-variant" && !form.addLevelId.isNullOrBlank() && !form.addNumber.isNullOrBlank() -> {
|
||||||
|
api.saveVariant(
|
||||||
|
t,
|
||||||
|
id,
|
||||||
|
null,
|
||||||
|
VariantSavePayload(
|
||||||
|
form.addLevelId,
|
||||||
|
form.addNumber.trim(),
|
||||||
|
form.addTitle.orEmpty().trim(),
|
||||||
|
form.addStatementMd.orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен")
|
||||||
|
}
|
||||||
|
else -> redirect.addFlashAttribute("notice", "Все изменения сохранены")
|
||||||
|
}
|
||||||
|
"redirect:/admin/labs/$id/edit"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$id/edit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/variants/save")
|
||||||
|
fun saveVariant(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@ModelAttribute form: SaveVariantForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.saveVariant(
|
||||||
|
token(session),
|
||||||
|
labId,
|
||||||
|
form.variantId,
|
||||||
|
VariantSavePayload(form.labLevelId, form.number, form.title, form.statementMd),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Вариант сохранён")
|
||||||
|
"redirect:/admin/labs/$labId/edit"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId/edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/variants/{variantId}/delete")
|
||||||
|
fun deleteVariant(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@PathVariable variantId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.deleteVariant(token(session), labId, variantId)
|
||||||
|
redirect.addFlashAttribute("notice", "Вариант удалён")
|
||||||
|
"redirect:/admin/labs/$labId/edit"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId/edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/set-level")
|
||||||
|
fun setLevel(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam studentId: String,
|
||||||
|
@RequestParam labLevelId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId))
|
||||||
|
redirect.addFlashAttribute("notice", "Уровень студента установлен")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ data class AdminLabStudent(
|
|||||||
val source: String,
|
val source: String,
|
||||||
val submissionId: String? = null,
|
val submissionId: String? = null,
|
||||||
val submissionSeq: Int? = null,
|
val submissionSeq: Int? = null,
|
||||||
|
val submissionFiles: List<String> = emptyList(),
|
||||||
val defenseStatus: String? = null,
|
val defenseStatus: String? = null,
|
||||||
val defenseCode: String? = null,
|
val defenseCode: String? = null,
|
||||||
val defenseSessionId: String? = null,
|
val defenseSessionId: String? = null,
|
||||||
@@ -34,6 +35,7 @@ data class AdminStudentGrades(
|
|||||||
val comments: List<List<Pair<String, String>>>,
|
val comments: List<List<Pair<String, String>>>,
|
||||||
val commentsJson: String,
|
val commentsJson: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
|
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
|
||||||
data class GroupLabCell(
|
data class GroupLabCell(
|
||||||
val labId: String = "",
|
val labId: String = "",
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.CreateStudentRequestDto
|
||||||
|
import ru.ysu.lms.web.api.createStudent
|
||||||
|
import ru.ysu.lms.web.api.credentials
|
||||||
|
import ru.ysu.lms.web.api.enrollStudent
|
||||||
|
import ru.ysu.lms.web.api.resetPassword
|
||||||
|
import ru.ysu.lms.web.api.unenrollStudent
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
class AdminStudentController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@PostMapping("/groups/{id}/students/{studentId}/reset-password")
|
||||||
|
fun resetPassword(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@PathVariable studentId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result = api.resetPassword(token(session), studentId)
|
||||||
|
redirect.addFlashAttribute("resetPassword", result.password)
|
||||||
|
redirect.addFlashAttribute("resetFor", studentId)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{id}/credentials")
|
||||||
|
fun credentials(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam(defaultValue = "xlsx") format: String,
|
||||||
|
session: HttpSession,
|
||||||
|
): ResponseEntity<ByteArray> {
|
||||||
|
val download = api.credentials(token(session), id, format)
|
||||||
|
val encoded = URLEncoder.encode(download.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
||||||
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
|
||||||
|
.contentType(MediaType.parseMediaType(download.contentType))
|
||||||
|
.body(download.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/students/create")
|
||||||
|
fun createStudent(
|
||||||
|
@PathVariable groupId: String,
|
||||||
|
@ModelAttribute form: CreateStudentForm,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
api.createStudent(
|
||||||
|
token(session),
|
||||||
|
groupId,
|
||||||
|
CreateStudentRequestDto(
|
||||||
|
studentNo = form.studentNo.trim(),
|
||||||
|
lastName = form.lastName.trim(),
|
||||||
|
firstName = form.firstName.orEmpty().trim(),
|
||||||
|
middleName = form.middleName.orEmpty().trim(),
|
||||||
|
basis = form.basis.orEmpty().trim(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("createdStudent", result)
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/students/enroll")
|
||||||
|
fun enrollStudent(
|
||||||
|
@PathVariable groupId: String,
|
||||||
|
@RequestParam studentId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.enrollStudent(token(session), groupId, studentId)
|
||||||
|
redirect.addFlashAttribute("notice", "Студент зачислен в группу")
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/students/{studentId}/unenroll")
|
||||||
|
fun unenrollStudent(
|
||||||
|
@PathVariable groupId: String,
|
||||||
|
@PathVariable studentId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.unenrollStudent(token(session), groupId, studentId)
|
||||||
|
redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)")
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/groups/$groupId"
|
||||||
|
}
|
||||||
|
}
|
||||||
267
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt
Normal file
267
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.LabSummaryDto
|
||||||
|
import ru.ysu.lms.web.api.StudentRowDto
|
||||||
|
import ru.ysu.lms.web.api.assignDefense
|
||||||
|
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
||||||
|
import ru.ysu.lms.web.api.groupDetail
|
||||||
|
import ru.ysu.lms.web.api.labSubmissions
|
||||||
|
import ru.ysu.lms.web.api.listGroups
|
||||||
|
import ru.ysu.lms.web.api.listLabs
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
abstract class AdminSupport(
|
||||||
|
protected val api: ApiClient,
|
||||||
|
protected val markdown: MarkdownRenderer,
|
||||||
|
protected val mapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
protected fun importDefenses(
|
||||||
|
t: String,
|
||||||
|
labId: String?,
|
||||||
|
file: MultipartFile,
|
||||||
|
groupId: String?,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val studentByNo = mutableMapOf<String, String>()
|
||||||
|
for (g in api.listGroups(t)) {
|
||||||
|
api.groupDetail(t, g.id).students.forEach { studentByNo[it.studentNo] = it.id }
|
||||||
|
}
|
||||||
|
val labByNumber = api.listLabs(t).associateBy { it.number.toString() }
|
||||||
|
val rows = parseCsv(file.bytes)
|
||||||
|
var count = 0
|
||||||
|
var skipped = 0
|
||||||
|
for ((idx, rawCols) in rows.withIndex()) {
|
||||||
|
val row = resolveDefenseRow(rawCols, idx, labId, studentByNo, labByNumber)
|
||||||
|
if (row == null) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
api.assignDefense(t, row.studentId, row.labId, row.assignmentMd.replace("\\n", "\n"))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tail = if (skipped > 0) ", пропущено: $skipped" else ""
|
||||||
|
redirect.addFlashAttribute("notice", "Импортировано заданий: $count$tail")
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectToGroup(groupId, labId)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun resolveDefenseRow(
|
||||||
|
rawCols: List<String>,
|
||||||
|
index: Int,
|
||||||
|
defaultLabId: String?,
|
||||||
|
studentByNo: Map<String, String>,
|
||||||
|
labByNumber: Map<String, LabSummaryDto>,
|
||||||
|
): DefenseRow? {
|
||||||
|
val cells = rawCols.map { it.trim() }
|
||||||
|
if (cells.all { it.isBlank() }) return null
|
||||||
|
if (index == 0 && looksLikeHeader(cells)) return null
|
||||||
|
var sid: String? = null
|
||||||
|
var lid: String? = defaultLabId?.takeIf { it.isNotBlank() }
|
||||||
|
var md: String? = null
|
||||||
|
when {
|
||||||
|
cells.size >= 3 -> {
|
||||||
|
sid = studentByNo[cells[0]]
|
||||||
|
lid = labByNumber[cells[1]]?.id ?: lid
|
||||||
|
md = cells.drop(2).joinToString("; ")
|
||||||
|
}
|
||||||
|
cells.size == 2 -> {
|
||||||
|
sid = studentByNo[cells[0]]
|
||||||
|
md = cells[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sid == null || lid.isNullOrBlank() || md.isNullOrBlank()) return null
|
||||||
|
return DefenseRow(sid, lid, md)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun looksLikeHeader(cells: List<String>): Boolean {
|
||||||
|
val joined = cells.joinToString(" ").lowercase()
|
||||||
|
return joined.contains("студент") ||
|
||||||
|
joined.contains("student") ||
|
||||||
|
joined.contains("фио") ||
|
||||||
|
joined.contains("задан") ||
|
||||||
|
joined.contains("assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Простой CSV: разделитель ; , или таб; поддержка кавычек и экранирования "" */
|
||||||
|
protected fun parseCsv(bytes: ByteArray): List<List<String>> {
|
||||||
|
val text = String(bytes, Charsets.UTF_8).removePrefix("\uFEFF")
|
||||||
|
val lines = text.lines().filter { it.isNotBlank() }
|
||||||
|
val sample = lines.firstOrNull() ?: return emptyList()
|
||||||
|
val delimiter =
|
||||||
|
when {
|
||||||
|
sample.contains('\t') -> '\t'
|
||||||
|
sample.contains(';') -> ';'
|
||||||
|
else -> ','
|
||||||
|
}
|
||||||
|
return lines.map { splitCsvLine(it, delimiter) }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun splitCsvLine(
|
||||||
|
line: String,
|
||||||
|
delimiter: Char,
|
||||||
|
): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var inQuotes = false
|
||||||
|
var i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
val c = line[i]
|
||||||
|
when {
|
||||||
|
inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> {
|
||||||
|
sb.append('"')
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
inQuotes && c == '"' -> inQuotes = false
|
||||||
|
inQuotes -> sb.append(c)
|
||||||
|
c == '"' -> inQuotes = true
|
||||||
|
c == delimiter -> {
|
||||||
|
out.add(sb.toString())
|
||||||
|
sb.setLength(0)
|
||||||
|
}
|
||||||
|
else -> sb.append(c)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.add(sb.toString())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun redirectToGroup(
|
||||||
|
groupId: String?,
|
||||||
|
labId: String?,
|
||||||
|
): String =
|
||||||
|
when {
|
||||||
|
!groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId"
|
||||||
|
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
|
||||||
|
else -> "redirect:/admin/groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun buildGroupArchive(
|
||||||
|
token: String,
|
||||||
|
groupName: String,
|
||||||
|
studentById: Map<String, StudentRowDto>,
|
||||||
|
labs: List<LabSummaryDto>,
|
||||||
|
): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
ZipOutputStream(out).use { zos ->
|
||||||
|
zos.putNextEntry(ZipEntry("ОПИСАНИЕ.txt").apply { time = 0 })
|
||||||
|
zos.write("Архив сдач группы $groupName (последние попытки)\n".toByteArray(Charsets.UTF_8))
|
||||||
|
zos.closeEntry()
|
||||||
|
for (lab in labs) {
|
||||||
|
for (sub in api.labSubmissions(token, lab.id)) {
|
||||||
|
val student = studentById[sub.studentId] ?: continue
|
||||||
|
val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes
|
||||||
|
appendSubmissionArchive(zos, lab, student, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun appendSubmissionArchive(
|
||||||
|
zos: ZipOutputStream,
|
||||||
|
lab: LabSummaryDto,
|
||||||
|
student: StudentRowDto,
|
||||||
|
submissionZip: ByteArray,
|
||||||
|
) {
|
||||||
|
ZipInputStream(ByteArrayInputStream(submissionZip)).use { zin ->
|
||||||
|
var entry = zin.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
val safeName = student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
zos.putNextEntry(ZipEntry("ЛР${lab.number}/${student.studentNo}_$safeName/${entry.name}").apply { time = 0 })
|
||||||
|
zin.copyTo(zos)
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
entry = zin.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun zipAttachment(
|
||||||
|
name: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): ResponseEntity<ByteArray> {
|
||||||
|
val encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
|
||||||
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.body(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человекочитаемое имя архива сдачи: группа-номер студенческого-ФИО-попыткаN.zip */
|
||||||
|
protected fun submissionFilename(
|
||||||
|
group: String?,
|
||||||
|
no: String?,
|
||||||
|
name: String?,
|
||||||
|
attempt: String?,
|
||||||
|
): String? {
|
||||||
|
val parts =
|
||||||
|
listOfNotNull(
|
||||||
|
group?.takeIf { it.isNotBlank() },
|
||||||
|
no?.takeIf { it.isNotBlank() },
|
||||||
|
name?.takeIf { it.isNotBlank() },
|
||||||
|
attempt?.takeIf { it.isNotBlank() }?.let { "попытка$it" },
|
||||||
|
)
|
||||||
|
if (parts.isEmpty()) return null
|
||||||
|
val base =
|
||||||
|
parts
|
||||||
|
.joinToString("-")
|
||||||
|
.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
|
||||||
|
.trim()
|
||||||
|
return if (base.isBlank()) null else "$base.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun parseComments(json: String): List<ru.ysu.lms.web.api.CommentPayload> {
|
||||||
|
if (json.isBlank() || json == "[]") return emptyList()
|
||||||
|
return try {
|
||||||
|
val node = mapper.readTree(json)
|
||||||
|
buildList {
|
||||||
|
node.forEach { el ->
|
||||||
|
val text = el.path("text").asString("").trim()
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
add(
|
||||||
|
ru.ysu.lms.web.api
|
||||||
|
.CommentPayload(text, el.path("tone").asString("neutral")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun token(session: HttpSession): String =
|
||||||
|
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||||
|
|
||||||
|
protected data class DefenseRow(
|
||||||
|
val studentId: String,
|
||||||
|
val labId: String,
|
||||||
|
val assignmentMd: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Разбирает строку CSV с заданием на защиту; null — строку нужно пропустить. */
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package ru.ysu.lms.web.admin
|
|||||||
|
|
||||||
import jakarta.servlet.http.HttpSession
|
import jakarta.servlet.http.HttpSession
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -16,7 +16,24 @@ import ru.ysu.lms.web.api.ApiClient
|
|||||||
import ru.ysu.lms.web.api.ApiException
|
import ru.ysu.lms.web.api.ApiException
|
||||||
import ru.ysu.lms.web.api.ArticlesReorderDto
|
import ru.ysu.lms.web.api.ArticlesReorderDto
|
||||||
import ru.ysu.lms.web.api.ContentReorderDto
|
import ru.ysu.lms.web.api.ContentReorderDto
|
||||||
|
import ru.ysu.lms.web.api.CreateArticlePayload
|
||||||
import ru.ysu.lms.web.api.SectionsReorderDto
|
import ru.ysu.lms.web.api.SectionsReorderDto
|
||||||
|
import ru.ysu.lms.web.api.createArticle
|
||||||
|
import ru.ysu.lms.web.api.createChapter
|
||||||
|
import ru.ysu.lms.web.api.createSection
|
||||||
|
import ru.ysu.lms.web.api.deleteChapter
|
||||||
|
import ru.ysu.lms.web.api.deleteContentArticle
|
||||||
|
import ru.ysu.lms.web.api.deleteSection
|
||||||
|
import ru.ysu.lms.web.api.deleteSectionWithMove
|
||||||
|
import ru.ysu.lms.web.api.importContent
|
||||||
|
import ru.ysu.lms.web.api.moveArticle
|
||||||
|
import ru.ysu.lms.web.api.renameChapter
|
||||||
|
import ru.ysu.lms.web.api.renameSection
|
||||||
|
import ru.ysu.lms.web.api.reorderArticles
|
||||||
|
import ru.ysu.lms.web.api.reorderContent
|
||||||
|
import ru.ysu.lms.web.api.reorderSections
|
||||||
|
import ru.ysu.lms.web.api.setContentPublished
|
||||||
|
import ru.ysu.lms.web.api.updateContentArticle
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -24,9 +41,8 @@ import ru.ysu.lms.web.session.SessionKeys
|
|||||||
class ContentAdminController(
|
class ContentAdminController(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun index(session: HttpSession): String = "redirect:/learn"
|
fun index(): String = "redirect:/learn"
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@@ -58,7 +74,10 @@ class ContentAdminController(
|
|||||||
/** Сохранение порядка при drag & drop (запрос отправляется из JS). */
|
/** Сохранение порядка при drag & drop (запрос отправляется из JS). */
|
||||||
@PostMapping("/reorder")
|
@PostMapping("/reorder")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
fun reorder(@RequestBody body: ContentReorderDto, session: HttpSession): Map<String, Any> {
|
fun reorder(
|
||||||
|
@RequestBody body: ContentReorderDto,
|
||||||
|
session: HttpSession,
|
||||||
|
): Map<String, Any> {
|
||||||
api.reorderContent(token(session), body)
|
api.reorderContent(token(session), body)
|
||||||
return mapOf("ok" to true)
|
return mapOf("ok" to true)
|
||||||
}
|
}
|
||||||
@@ -90,18 +109,17 @@ class ContentAdminController(
|
|||||||
@PostMapping("/{kind}/toggle-published")
|
@PostMapping("/{kind}/toggle-published")
|
||||||
fun togglePublished(
|
fun togglePublished(
|
||||||
@PathVariable kind: String,
|
@PathVariable kind: String,
|
||||||
@RequestParam id: String,
|
@ModelAttribute form: TogglePublishedForm,
|
||||||
@RequestParam published: Boolean,
|
|
||||||
@RequestParam(required = false) back: String?,
|
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
api.setContentPublished(token(session), kind, id, published)
|
try {
|
||||||
|
api.setContentPublished(token(session), kind, form.id, form.published)
|
||||||
redirect.addFlashAttribute(
|
redirect.addFlashAttribute(
|
||||||
"notice",
|
"notice",
|
||||||
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
|
if (form.published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
|
||||||
)
|
)
|
||||||
redirectBack(back, "redirect:/learn")
|
redirectBack(form.back, "redirect:/learn")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/learn"
|
"redirect:/learn"
|
||||||
@@ -110,15 +128,14 @@ class ContentAdminController(
|
|||||||
@PostMapping("/articles/{id}/save")
|
@PostMapping("/articles/{id}/save")
|
||||||
fun save(
|
fun save(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam title: String,
|
@ModelAttribute form: ArticleSaveForm,
|
||||||
@RequestParam bodyMd: String,
|
|
||||||
@RequestParam(required = false) back: String?,
|
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
api.updateContentArticle(token(session), id, title, bodyMd)
|
try {
|
||||||
|
api.updateContentArticle(token(session), id, form.title, form.bodyMd)
|
||||||
redirect.addFlashAttribute("notice", "Статья сохранена")
|
redirect.addFlashAttribute("notice", "Статья сохранена")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(form.back, "redirect:/admin/content")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/content/articles/$id/edit"
|
"redirect:/admin/content/articles/$id/edit"
|
||||||
@@ -130,7 +147,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.deleteContentArticle(token(session), id)
|
api.deleteContentArticle(token(session), id)
|
||||||
redirect.addFlashAttribute("notice", "Статья удалена")
|
redirect.addFlashAttribute("notice", "Статья удалена")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -146,7 +164,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.createChapter(token(session), title.trim(), slug)
|
api.createChapter(token(session), title.trim(), slug)
|
||||||
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -162,7 +181,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.renameChapter(token(session), id, title.trim())
|
api.renameChapter(token(session), id, title.trim())
|
||||||
redirect.addFlashAttribute("notice", "Глава переименована")
|
redirect.addFlashAttribute("notice", "Глава переименована")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -177,7 +197,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.deleteChapter(token(session), id)
|
api.deleteChapter(token(session), id)
|
||||||
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
|
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -188,16 +209,14 @@ class ContentAdminController(
|
|||||||
|
|
||||||
@PostMapping("/sections/create")
|
@PostMapping("/sections/create")
|
||||||
fun createSection(
|
fun createSection(
|
||||||
@RequestParam chapterId: String,
|
@ModelAttribute form: SectionCreateForm,
|
||||||
@RequestParam title: String,
|
|
||||||
@RequestParam(required = false) slug: String?,
|
|
||||||
@RequestParam(required = false) back: String?,
|
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
api.createSection(token(session), chapterId, title.trim(), slug)
|
try {
|
||||||
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
|
api.createSection(token(session), form.chapterId, form.title.trim(), form.slug)
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirect.addFlashAttribute("notice", "Раздел «${form.title.trim()}» создан")
|
||||||
|
redirectBack(form.back, "redirect:/admin/content")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/content"
|
"redirect:/admin/content"
|
||||||
@@ -210,7 +229,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.renameSection(token(session), id, title.trim())
|
api.renameSection(token(session), id, title.trim())
|
||||||
redirect.addFlashAttribute("notice", "Раздел переименован")
|
redirect.addFlashAttribute("notice", "Раздел переименован")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -226,7 +246,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
if (moveTo.isNullOrBlank()) {
|
if (moveTo.isNullOrBlank()) {
|
||||||
api.deleteSection(token(session), id)
|
api.deleteSection(token(session), id)
|
||||||
redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями")
|
redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями")
|
||||||
@@ -242,24 +263,24 @@ class ContentAdminController(
|
|||||||
|
|
||||||
@PostMapping("/articles/create")
|
@PostMapping("/articles/create")
|
||||||
fun createArticle(
|
fun createArticle(
|
||||||
@RequestParam(required = false) chapterId: String?,
|
@ModelAttribute form: ArticleCreateForm,
|
||||||
@RequestParam(required = false) sectionId: String?,
|
|
||||||
@RequestParam(required = false) newChapterTitle: String?,
|
|
||||||
@RequestParam(required = false) newSectionTitle: String?,
|
|
||||||
@RequestParam title: String,
|
|
||||||
@RequestParam(required = false, defaultValue = "") bodyMd: String,
|
|
||||||
@RequestParam(required = false) back: String?,
|
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.createArticle(
|
api.createArticle(
|
||||||
token(session), chapterId, sectionId,
|
token(session),
|
||||||
newChapterTitle?.takeIf { it.isNotBlank() },
|
CreateArticlePayload(
|
||||||
newSectionTitle?.takeIf { it.isNotBlank() },
|
chapterId = form.chapterId,
|
||||||
title.trim(), bodyMd,
|
sectionId = form.sectionId,
|
||||||
|
newChapterTitle = form.newChapterTitle?.takeIf { it.isNotBlank() },
|
||||||
|
newSectionTitle = form.newSectionTitle?.takeIf { it.isNotBlank() },
|
||||||
|
title = form.title.trim(),
|
||||||
|
bodyMd = form.bodyMd,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
|
redirect.addFlashAttribute("notice", "Статья «${form.title.trim()}» создана")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(form.back, "redirect:/admin/content")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/content"
|
"redirect:/admin/content"
|
||||||
@@ -272,7 +293,8 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = try {
|
): String =
|
||||||
|
try {
|
||||||
api.moveArticle(token(session), id, sectionId)
|
api.moveArticle(token(session), id, sectionId)
|
||||||
redirect.addFlashAttribute("notice", "Статья перемещена")
|
redirect.addFlashAttribute("notice", "Статья перемещена")
|
||||||
redirectBack(back, "redirect:/admin/content")
|
redirectBack(back, "redirect:/admin/content")
|
||||||
@@ -282,7 +304,10 @@ class ContentAdminController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
|
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
|
||||||
private fun redirectBack(back: String?, fallback: String): String =
|
private fun redirectBack(
|
||||||
|
back: String?,
|
||||||
|
fallback: String,
|
||||||
|
): String =
|
||||||
if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) {
|
if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) {
|
||||||
"redirect:$back"
|
"redirect:$back"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
/** Формы админки материалов: связываются через @ModelAttribute, имена полей = имена в шаблонах. */
|
||||||
|
data class TogglePublishedForm(
|
||||||
|
val id: String = "",
|
||||||
|
val published: Boolean = false,
|
||||||
|
val back: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ArticleSaveForm(
|
||||||
|
val title: String = "",
|
||||||
|
val bodyMd: String = "",
|
||||||
|
val back: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SectionCreateForm(
|
||||||
|
val chapterId: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String? = null,
|
||||||
|
val back: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ArticleCreateForm(
|
||||||
|
val chapterId: String? = null,
|
||||||
|
val sectionId: String? = null,
|
||||||
|
val newChapterTitle: String? = null,
|
||||||
|
val newSectionTitle: String? = null,
|
||||||
|
val title: String = "",
|
||||||
|
val bodyMd: String = "",
|
||||||
|
val back: String? = null,
|
||||||
|
)
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package ru.ysu.lms.web.api
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.core.ParameterizedTypeReference
|
|
||||||
import org.springframework.core.io.ByteArrayResource
|
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.util.LinkedMultiValueMap
|
|
||||||
import org.springframework.web.client.RestClient
|
import org.springframework.web.client.RestClient
|
||||||
import org.springframework.web.client.RestClientException
|
import org.springframework.web.client.RestClientException
|
||||||
import org.springframework.web.client.RestClientResponseException
|
import org.springframework.web.client.RestClientResponseException
|
||||||
@@ -17,422 +13,41 @@ import java.nio.charset.StandardCharsets
|
|||||||
class ApiClient(
|
class ApiClient(
|
||||||
@Value("\${lms.gateway.base-url}") baseUrl: String,
|
@Value("\${lms.gateway.base-url}") baseUrl: String,
|
||||||
) {
|
) {
|
||||||
private val client: RestClient = RestClient.builder().baseUrl(baseUrl).build()
|
internal val client: RestClient = RestClient.builder().baseUrl(baseUrl).build()
|
||||||
|
|
||||||
fun adminExists(): Boolean =
|
internal fun bearer(token: String): String = "Bearer $token"
|
||||||
call { client.get().uri("/api/v1/auth/health").retrieve().body(HealthDto::class.java) }.adminExists
|
|
||||||
|
|
||||||
fun setup(login: String, password: String, password2: String): AuthResponseDto =
|
internal fun <T> callOrNull(block: () -> T?): T? =
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/auth/setup")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(SetupPayload(login, password, password2))
|
|
||||||
.retrieve().body(AuthResponseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun login(login: String, password: String): AuthResponseDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/auth/login")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(LoginPayload(login, password))
|
|
||||||
.retrieve().body(AuthResponseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changePassword(token: String, old: String, new: String, new2: String) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/auth/change-password")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(ChangePasswordPayload(old, new, new2))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listGroups(token: String): List<GroupDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/groups")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createGroup(token: String, name: String): GroupDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/groups")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(CreateGroupPayload(name))
|
|
||||||
.retrieve().body(GroupDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importGroup(
|
|
||||||
token: String,
|
|
||||||
name: String?,
|
|
||||||
filename: String,
|
|
||||||
bytes: ByteArray,
|
|
||||||
): ImportResultDto {
|
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
|
||||||
parts.add(
|
|
||||||
"file",
|
|
||||||
object : ByteArrayResource(bytes) {
|
|
||||||
override fun getFilename(): String = filename
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!name.isNullOrBlank()) {
|
|
||||||
parts.add("name", name)
|
|
||||||
}
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/groups/import")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
|
||||||
.body(parts)
|
|
||||||
.retrieve().body(ImportResultDto::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupDetail(token: String, groupId: String): GroupDetailDto =
|
|
||||||
call {
|
|
||||||
client.get().uri("/api/v1/groups/{id}", groupId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(GroupDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupCandidates(token: String, groupId: String): List<StudentCandidateDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<StudentCandidateDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/groups/{id}/candidates", groupId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enrollStudent(token: String, groupId: String, studentId: String) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/groups/{id}/students/{sid}/enroll", groupId, studentId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unenrollStudent(token: String, groupId: String, studentId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetPassword(token: String, studentId: String): ResetPasswordDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/students/{id}/reset-password", studentId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(ResetPasswordDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun credentials(token: String, groupId: String, format: String): DownloadDto {
|
|
||||||
val entity = call {
|
|
||||||
client.get()
|
|
||||||
.uri { builder ->
|
|
||||||
builder.path("/api/v1/groups/{id}/credentials").queryParam("format", format).build(groupId)
|
|
||||||
}
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toEntity(ByteArray::class.java)
|
|
||||||
}
|
|
||||||
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
|
|
||||||
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "credentials.$format" }
|
|
||||||
val filename = URLDecoder.decode(rawName, StandardCharsets.UTF_8)
|
|
||||||
val contentType = entity.headers.contentType?.toString() ?: "application/octet-stream"
|
|
||||||
return DownloadDto(entity.body ?: ByteArray(0), filename, contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bearer(token: String): String = "Bearer $token"
|
|
||||||
|
|
||||||
private fun <T> callOrNull(block: () -> T?): T? =
|
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (ex: RestClientResponseException) {
|
} catch (ignore: RestClientResponseException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> call(block: () -> T?): T =
|
internal fun <T> call(block: () -> T?): T =
|
||||||
try {
|
try {
|
||||||
block() ?: throw ApiException(502, "Пустой ответ сервиса")
|
block() ?: throw ApiException(502, "Пустой ответ сервиса")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
throw ex
|
throw ex
|
||||||
} catch (ex: RestClientResponseException) {
|
} catch (ex: RestClientResponseException) {
|
||||||
throw ApiException(ex.statusCode.value(), detail(ex))
|
throw ApiException(ex.statusCode.value(), detail(ex))
|
||||||
} catch (ex: RestClientException) {
|
} catch (ignore: RestClientException) {
|
||||||
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
|
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detail(ex: RestClientResponseException): String {
|
internal fun detail(ex: RestClientResponseException): String {
|
||||||
val body = ex.responseBodyAsString
|
val body = ex.responseBodyAsString
|
||||||
val match = DETAIL_PATTERN.find(body)
|
val match = DETAIL_PATTERN.find(body)
|
||||||
val parsed = match?.groupValues?.get(1)
|
val parsed =
|
||||||
|
match
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
?.replace("\\\"", "\"")
|
?.replace("\\\"", "\"")
|
||||||
?.replace("\\n", "\n")
|
?.replace("\\n", "\n")
|
||||||
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
|
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun listLabs(token: String): List<LabSummaryDto> {
|
internal fun downloadOf(entity: org.springframework.http.ResponseEntity<ByteArray>): DownloadDto {
|
||||||
val type = object : ParameterizedTypeReference<List<LabSummaryDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/labs")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun labDetail(token: String, labId: String): LabDetailDto =
|
|
||||||
call {
|
|
||||||
client.get().uri("/api/v1/labs/{id}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importLab(token: String, filename: String, bytes: ByteArray): LabImportResultDto {
|
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
|
||||||
parts.add("file", object : ByteArrayResource(bytes) {
|
|
||||||
override fun getFilename(): String = filename
|
|
||||||
})
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/labs/import")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
|
||||||
.body(parts)
|
|
||||||
.retrieve().body(LabImportResultDto::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteLab(token: String, labId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/labs/{id}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLabStatus(token: String, labId: String) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/labs/{id}/toggle-status", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLevels(token: String, labId: String): List<LevelAssignmentResultDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<LevelAssignmentResultDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/levels?labId={labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLevel(token: String, payload: LevelAssignPayload): LevelAssignmentResultDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/levels")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(LevelAssignmentResultDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun distribute(token: String, payload: DistributeRequestPayload): DistributeResultDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/distribute")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(DistributeResultDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun assignVariant(token: String, payload: AssignVariantPayload): VariantAssignmentResultDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/assign")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(VariantAssignmentResultDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLabAssignments(token: String, labId: String): List<VariantAssignmentResultDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<VariantAssignmentResultDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/assignments?labId={labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun meAssignments(token: String): List<MeAssignmentDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<MeAssignmentDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/me/assignments")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLab(token: String, labId: String, payload: LabUpdatePayload): LabDetailDto =
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/labs/{id}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveVariant(token: String, labId: String, variantId: String?, payload: VariantSavePayload): LabDetailDto =
|
|
||||||
if (variantId.isNullOrBlank()) {
|
|
||||||
addVariant(token, labId, payload)
|
|
||||||
} else {
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addVariant(token: String, labId: String, payload: VariantSavePayload): LabDetailDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/labs/{id}/variants", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteVariant(token: String, labId: String, variantId: String): LabDetailDto =
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listGrades(token: String): List<GradeDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/grades")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setGrade(token: String, payload: GradeSetPayload): GradeDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/grades")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(GradeDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun suggestComments(token: String, query: String): List<CommentDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<CommentDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/comment-templates?q={q}", query)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
|
||||||
|
|
||||||
fun myGrades(token: String, groupId: String?): List<GradeDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
|
||||||
return call {
|
|
||||||
val uri = if (groupId.isNullOrBlank()) "/api/v1/me/grades"
|
|
||||||
else "/api/v1/me/grades?groupId={gid}"
|
|
||||||
val spec = client.get()
|
|
||||||
.uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId))
|
|
||||||
spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupPublications(token: String, groupId: String): List<String> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<String>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/grades/publications?groupId={gid}", groupId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun publishGrades(token: String, labId: String, groupId: String, published: Boolean) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun myGroup(token: String): List<GroupDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/groups/me")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submitLab(token: String, labId: String, files: List<Pair<String, ByteArray>>): List<SubmissionDto> {
|
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
|
||||||
for ((name, bytes) in files) {
|
|
||||||
parts.add("files", object : ByteArrayResource(bytes) {
|
|
||||||
override fun getFilename(): String = name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/me/submissions/{labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
|
||||||
.body(parts)
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mySubmissions(token: String, labId: String): List<SubmissionDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/me/submissions?labId={labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun labSubmissions(token: String, labId: String): List<SubmissionDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<SubmissionDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/submissions?labId={labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadMySubmission(token: String, submissionId: String): DownloadDto {
|
|
||||||
val entity = call {
|
|
||||||
client.get().uri("/api/v1/me/submissions/{id}/download", submissionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toEntity(ByteArray::class.java)
|
|
||||||
}
|
|
||||||
return downloadOf(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadSubmissionAsAdmin(token: String, submissionId: String): DownloadDto {
|
|
||||||
val entity = call {
|
|
||||||
client.get().uri("/api/v1/submissions/{id}/download", submissionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toEntity(ByteArray::class.java)
|
|
||||||
}
|
|
||||||
return downloadOf(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadOf(entity: org.springframework.http.ResponseEntity<ByteArray>): DownloadDto {
|
|
||||||
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
|
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
|
||||||
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" }
|
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" }
|
||||||
return DownloadDto(
|
return DownloadDto(
|
||||||
@@ -442,352 +57,6 @@ class ApiClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun contentTree(token: String?, all: Boolean = false): ContentTreeNodeDto =
|
|
||||||
call {
|
|
||||||
val uri = if (all) "/api/v1/content/tree/all" else "/api/v1/content/tree"
|
|
||||||
val spec = client.get().uri(uri)
|
|
||||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
|
||||||
.retrieve().body(ContentTreeNodeDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contentArticle(
|
|
||||||
token: String?,
|
|
||||||
chapter: String,
|
|
||||||
section: String,
|
|
||||||
article: String,
|
|
||||||
all: Boolean = false,
|
|
||||||
): ContentArticleDto =
|
|
||||||
call {
|
|
||||||
val uri = if (all) "/api/v1/content/all/{c}/{s}/{a}" else "/api/v1/content/{c}/{s}/{a}"
|
|
||||||
val spec = client.get().uri(uri, chapter, section, article)
|
|
||||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
|
||||||
.retrieve().body(ContentArticleDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Публикация/скрытие главы, раздела или статьи. kind: chapters|sections|articles */
|
|
||||||
fun setContentPublished(token: String, kind: String, id: String, published: Boolean) {
|
|
||||||
val path = when (kind) {
|
|
||||||
"chapters" -> "/api/v1/content/chapters/{id}/published"
|
|
||||||
"sections" -> "/api/v1/content/sections/{id}/published"
|
|
||||||
"articles" -> "/api/v1/content/articles/{id}/published"
|
|
||||||
else -> throw IllegalArgumentException("Неизвестный тип материала: $kind")
|
|
||||||
}
|
|
||||||
call {
|
|
||||||
client.put().uri(path, id)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("published" to published))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contentTreeList(token: String?): List<ContentArticleDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
|
|
||||||
return call {
|
|
||||||
val spec = client.get().uri("/api/v1/content/articles")
|
|
||||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сохраняет новое расположение глав, разделов и статей. */
|
|
||||||
fun reorderContent(token: String, tree: ContentReorderDto) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/content/reorder")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(tree)
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Порядок разделов внутри главы (drag & drop на странице главы). */
|
|
||||||
fun reorderSections(token: String, chapterId: String, body: SectionsReorderDto) {
|
|
||||||
call {
|
|
||||||
client.post()
|
|
||||||
.uri("/api/v1/content/chapters/{id}/sections/reorder", chapterId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(body)
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
|
|
||||||
fun reorderArticles(token: String, sectionId: String, body: ArticlesReorderDto) {
|
|
||||||
call {
|
|
||||||
client.post()
|
|
||||||
.uri("/api/v1/content/sections/{id}/articles/reorder", sectionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(body)
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importContent(
|
|
||||||
token: String,
|
|
||||||
filename: String,
|
|
||||||
bytes: ByteArray,
|
|
||||||
sync: Boolean = true,
|
|
||||||
): ContentImportResultDto {
|
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
|
||||||
parts.add("sync", sync.toString())
|
|
||||||
parts.add("file", object : ByteArrayResource(bytes) {
|
|
||||||
override fun getFilename(): String = filename
|
|
||||||
})
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/content/import")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
|
||||||
.body(parts)
|
|
||||||
.retrieve().body(ContentImportResultDto::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateContentArticle(token: String, articleId: String, title: String, bodyMd: String) {
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/content/articles/{id}", articleId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title, "bodyMd" to bodyMd))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteContentArticle(token: String, articleId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/content/articles/{id}", articleId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contentAsset(name: String): DownloadDto {
|
|
||||||
val entity = call {
|
|
||||||
client.get().uri("/api/v1/content/assets/{name}", name)
|
|
||||||
.retrieve().toEntity(ByteArray::class.java)
|
|
||||||
}
|
|
||||||
return DownloadDto(
|
|
||||||
bytes = entity.body ?: ByteArray(0),
|
|
||||||
filename = name,
|
|
||||||
contentType = entity.headers.contentType?.toString() ?: "application/octet-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createLab(token: String, payload: LabCreatePayload): LabDetailDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/labs")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(LabDetailDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addLevel(token: String, labId: String, payload: LevelSavePayload) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/labs/{id}/levels", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun renameLevel(token: String, labId: String, levelId: String, title: String) {
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteLevel(token: String, labId: String, levelId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createStudent(token: String, groupId: String, payload: CreateStudentRequestDto): CredentialDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/groups/{id}/students", groupId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(payload)
|
|
||||||
.retrieve().body(CredentialDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChapter(token: String, title: String, slug: String?): Map<String, Any> {
|
|
||||||
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/content/chapters")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title, "slug" to slug))
|
|
||||||
.retrieve().body(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun renameChapter(token: String, chapterId: String, title: String) {
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/content/chapters/{id}", chapterId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteChapter(token: String, chapterId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/content/chapters/{id}", chapterId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createSection(token: String, chapterId: String, title: String, slug: String?): Map<String, Any> {
|
|
||||||
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
|
|
||||||
return call {
|
|
||||||
client.post().uri("/api/v1/content/sections?chapterId={cid}", chapterId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title, "slug" to slug))
|
|
||||||
.retrieve().body(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun renameSection(token: String, sectionId: String, title: String) {
|
|
||||||
call {
|
|
||||||
client.put().uri("/api/v1/content/sections/{id}", sectionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("title" to title))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSection(token: String, sectionId: String) {
|
|
||||||
call {
|
|
||||||
client.delete().uri("/api/v1/content/sections/{id}", sectionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSectionWithMove(token: String, sectionId: String, moveTo: String) {
|
|
||||||
call {
|
|
||||||
client.delete()
|
|
||||||
.uri { b -> b.path("/api/v1/content/sections/{id}").queryParam("moveTo", moveTo).build(sectionId) }
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createArticle(
|
|
||||||
token: String, chapterId: String?, sectionId: String?,
|
|
||||||
newChapterTitle: String?, newSectionTitle: String?,
|
|
||||||
title: String, bodyMd: String,
|
|
||||||
): ContentArticleDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/content/articles")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(
|
|
||||||
mapOf(
|
|
||||||
"chapterId" to chapterId,
|
|
||||||
"sectionId" to sectionId,
|
|
||||||
"newChapterTitle" to newChapterTitle,
|
|
||||||
"newSectionTitle" to newSectionTitle,
|
|
||||||
"title" to title,
|
|
||||||
"bodyMd" to bodyMd,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.retrieve().body(ContentArticleDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveArticle(token: String, articleId: String, newSectionId: String): ContentArticleDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/content/articles/{id}/move", articleId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(ArticleMovePayload(newSectionId))
|
|
||||||
.retrieve().body(ContentArticleDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Defense (индивидуальные задания защиты) ----------
|
|
||||||
|
|
||||||
fun labDefenses(token: String, labId: String): List<DefenseDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/defenses?labId={labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun assignDefense(token: String, studentId: String, labId: String, assignmentMd: String): DefenseDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/defenses/assign")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("studentId" to studentId, "labId" to labId, "assignmentMd" to assignmentMd))
|
|
||||||
.retrieve().body(DefenseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun issueDefenseCode(token: String, sessionId: String): String =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/defenses/{id}/issue-code", sessionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(Map::class.java)
|
|
||||||
}?.get("code").toString()
|
|
||||||
|
|
||||||
fun resetDefense(token: String, sessionId: String) {
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/defenses/{id}/reset", sessionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun myDefenses(token: String): List<DefenseDto> {
|
|
||||||
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
|
|
||||||
return call {
|
|
||||||
client.get().uri("/api/v1/me/defenses")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(type)
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun myDefense(token: String, labId: String): DefenseDto? =
|
|
||||||
callOrNull {
|
|
||||||
client.get().uri("/api/v1/me/defenses/{labId}", labId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve().body(DefenseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startDefense(token: String, code: String): DefenseDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/me/defenses/start")
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(mapOf("code" to code))
|
|
||||||
.retrieve().body(DefenseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finishDefense(token: String, sessionId: String): DefenseDto =
|
|
||||||
call {
|
|
||||||
client.post().uri("/api/v1/me/defenses/{id}/finish", sessionId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.retrieve().body(DefenseDto::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
|
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||||
}
|
}
|
||||||
|
|||||||
60
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt
Normal file
60
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
|
||||||
|
fun ApiClient.adminExists(): Boolean =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/auth/health")
|
||||||
|
.retrieve()
|
||||||
|
.body(HealthDto::class.java)
|
||||||
|
}.adminExists
|
||||||
|
|
||||||
|
fun ApiClient.setup(
|
||||||
|
login: String,
|
||||||
|
password: String,
|
||||||
|
password2: String,
|
||||||
|
): AuthResponseDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/auth/setup")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(SetupPayload(login, password, password2))
|
||||||
|
.retrieve()
|
||||||
|
.body(AuthResponseDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.login(
|
||||||
|
login: String,
|
||||||
|
password: String,
|
||||||
|
): AuthResponseDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(LoginPayload(login, password))
|
||||||
|
.retrieve()
|
||||||
|
.body(AuthResponseDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.changePassword(
|
||||||
|
token: String,
|
||||||
|
old: String,
|
||||||
|
new: String,
|
||||||
|
new2: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/auth/change-password")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(ChangePasswordPayload(old, new, new2))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user