Build: enable ktlint+detekt, format sources, add core unit tests

This commit is contained in:
2026-09-27 21:46:05 +03:00
parent d35d345059
commit c6e0b0d7c8
103 changed files with 3834 additions and 1909 deletions

14
.editorconfig Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,50 @@
# Конфигурация detekt для LMS. Значения подобраны под текущий стиль кода:
# базовые правила включены (buildUponDefaultConfig = true), шумные отключены или ослаблены.
build:
maxIssues: 0
weights: {}
config:
validation: true
warningsAsErrors: false
complexity:
LongMethod:
threshold: 60
LongParameterList:
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: true
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 20
ComplexCondition:
threshold: 4
style:
MagicNumber:
active: false
MaxLineLength:
active: false
ReturnCount:
max: 4
WildcardImport:
active: true
UnusedPrivateMember:
active: true
LoopWithTooManyJumpStatements:
maxJumpCount: 2
naming:
FunctionNaming:
ignoreAnnotated:
- 'Test'
MatchingDeclarationName:
active: false
empty-blocks:
EmptyCatchBlock:
allowedExceptionNameRegex: 'ignored|expected|_'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,16 +8,25 @@ 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 val isImage = ext in IMAGE_EXTENSIONS
@@ -50,22 +59,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return target to mimeFor(ext) return target to mimeFor(ext)
} }
private fun mimeFor(ext: String): String = when (ext) { private fun mimeFor(ext: String): String =
"png" -> "image/png" when (ext) {
"jpg", "jpeg" -> "image/jpeg" "png" -> "image/png"
"gif" -> "image/gif" "jpg", "jpeg" -> "image/jpeg"
"svg" -> "image/svg+xml" "gif" -> "image/gif"
"webp" -> "image/webp" "svg" -> "image/svg+xml"
"mp4" -> "video/mp4" "webp" -> "image/webp"
"webm" -> "video/webm" "mp4" -> "video/mp4"
"ogg", "ogv" -> "video/ogg" "webm" -> "video/webm"
else -> "application/octet-stream" "ogg", "ogv" -> "video/ogg"
} else -> "application/octet-stream"
}
private companion object { private companion object {
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp") val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv") val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
} }
} }

View File

@@ -40,7 +40,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,16 +70,33 @@ 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( private data class ParsedArticle(
val path: String, val path: String,
@@ -98,16 +117,19 @@ class ContentService(
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val assets: AssetStorage, private val assets: AssetStorage,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree { fun tree(includeHidden: Boolean = false): ContentTree {
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published } val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
val sectionList = sections.findAllByOrderByPositionAscIdAsc() val sectionList =
.filter { includeHidden || it.published } sections
.groupBy { it.chapterId } .findAllByOrderByPositionAscIdAsc()
val articleList = articles.findAllByOrderByPositionAscIdAsc() .filter { includeHidden || it.published }
.filter { includeHidden || it.published } .groupBy { it.chapterId }
.groupBy { it.sectionId } val articleList =
articles
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
return ContentTree( return ContentTree(
chapterList.map { ch -> chapterList.map { ch ->
ChapterTreeNode( ChapterTreeNode(
@@ -116,18 +138,20 @@ class ContentService(
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
sections = (sectionList[ch.id] ?: emptyList()).map { sec -> sections =
SectionTreeNode( (sectionList[ch.id] ?: emptyList()).map { sec ->
id = sec.id.toString(), SectionTreeNode(
title = sec.title, id = sec.id.toString(),
slug = sec.slug, title = sec.title,
position = sec.position, slug = sec.slug,
published = sec.published, position = sec.position,
articles = (articleList[sec.id] ?: emptyList()).map { a -> published = sec.published,
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published) articles =
}, (articleList[sec.id] ?: emptyList()).map { a ->
) ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
}, },
)
},
) )
}, },
) )
@@ -167,21 +191,24 @@ class ContentService(
@Transactional @Transactional
fun reorder(tree: ReorderTree) { fun reorder(tree: ReorderTree) {
tree.chapters.forEachIndexed { chapterIndex, chapterDto -> tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null) val chapter =
?: return@forEachIndexed chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
chapter.position = chapterIndex + 1 chapter.position = chapterIndex + 1
chapters.save(chapter) chapters.save(chapter)
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto -> chapterDto.sections.forEachIndexed { sectionIndex, sectionDto ->
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) val section =
?: return@forEachIndexed sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
section.chapterId = chapter.id section.chapterId = chapter.id
section.position = sectionIndex + 1 section.position = sectionIndex + 1
sections.save(section) sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto -> sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) val article =
?: return@forEachIndexed articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id article.sectionId = section.id
article.position = articleIndex + 1 article.position = articleIndex + 1
articles.save(article) articles.save(article)
@@ -192,17 +219,22 @@ class ContentService(
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */ /** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
@Transactional @Transactional
fun reorderSections(chapterId: UUID, tree: List<ReorderSection>) { fun reorderSections(
chapterId: UUID,
tree: List<ReorderSection>,
) {
tree.forEachIndexed { sectionIndex, sectionDto -> tree.forEachIndexed { sectionIndex, sectionDto ->
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) val section =
?: return@forEachIndexed sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (section.chapterId != chapterId) return@forEachIndexed if (section.chapterId != chapterId) return@forEachIndexed
section.position = sectionIndex + 1 section.position = sectionIndex + 1
sections.save(section) sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto -> sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) val article =
?: return@forEachIndexed articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id article.sectionId = section.id
article.position = articleIndex + 1 article.position = articleIndex + 1
articles.save(article) articles.save(article)
@@ -212,35 +244,47 @@ class ContentService(
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ /** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
@Transactional @Transactional
fun reorderArticles(sectionId: UUID, tree: List<ReorderArticle>) { fun reorderArticles(
sectionId: UUID,
tree: List<ReorderArticle>,
) {
tree.forEachIndexed { articleIndex, articleDto -> tree.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) val article =
?: return@forEachIndexed articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (article.sectionId != sectionId) return@forEachIndexed if (article.sectionId != sectionId) return@forEachIndexed
article.position = articleIndex + 1 article.position = articleIndex + 1
articles.save(article) articles.save(article)
} }
} }
private fun uuidOrNull(value: String): UUID? = private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
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,7 +311,11 @@ class ContentService(
} }
@Transactional @Transactional
fun updateArticle(articleId: UUID, title: String, bodyMd: String) { fun updateArticle(
articleId: UUID,
title: String,
bodyMd: String,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim() a.title = title.trim()
@@ -282,20 +330,31 @@ class ContentService(
} }
@Transactional @Transactional
fun createChapter(title: String, slug: String?): ChapterTreeNode { fun createChapter(
title: String,
slug: String?,
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) } val cleanSlug =
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()) slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1)) val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode( return ChapterTreeNode(
id = ch.id.toString(), title = ch.title, slug = ch.slug, id = ch.id.toString(),
position = ch.position, published = ch.published, sections = emptyList(), title = ch.title,
slug = ch.slug,
position = ch.position,
published = ch.published,
sections = emptyList(),
) )
} }
@Transactional @Transactional
fun renameChapter(chapterId: UUID, title: String) { fun renameChapter(
chapterId: UUID,
title: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim() ch.title = title.trim()
@@ -309,24 +368,37 @@ class ContentService(
} }
@Transactional @Transactional
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode { fun createSection(
chapterId: UUID,
title: String,
slug: String?,
): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) } val cleanSlug =
?: uniqueSlug(title, siblings.map { it.slug }.toSet()) slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0 val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec = sections.save( val sec =
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1), sections.save(
) ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
)
return SectionTreeNode( return SectionTreeNode(
id = sec.id.toString(), title = sec.title, slug = sec.slug, id = sec.id.toString(),
position = sec.position, published = sec.published, articles = emptyList(), title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles = emptyList(),
) )
} }
@Transactional @Transactional
fun renameSection(sectionId: UUID, title: String) { fun renameSection(
sectionId: UUID,
title: String,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim() sec.title = title.trim()
@@ -341,7 +413,10 @@ class ContentService(
/** Переносит все статьи раздела в другой и удаляет раздел. */ /** Переносит все статьи раздела в другой и удаляет раздел. */
@Transactional @Transactional
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) { fun moveArticlesAndDeleteSection(
sectionId: UUID,
targetSectionId: UUID,
) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
@@ -358,58 +433,88 @@ class ContentService(
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ /** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
@Transactional @Transactional
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?, fun createArticlePlaced(
sectionId: UUID?, newSectionTitle: String?): ContentArticleView { title: String,
val targetSection: ContentSection = when { bodyMd: String,
sectionId != null && !newSectionTitle.isNullOrBlank() -> { chapterId: UUID?,
val anchor = sections.findById(sectionId) newChapterTitle: String?,
.orElseThrow { NotFoundException("Раздел не найден") } sectionId: UUID?,
val node = createSection(anchor.chapterId, newSectionTitle, null) newSectionTitle: String?,
sections.findById(UUID.fromString(node.id)) ): ContentArticleView {
.orElseThrow { NotFoundException("Раздел не найден") } val targetSection: ContentSection =
} when {
sectionId != null -> sections.findById(sectionId) sectionId != null && !newSectionTitle.isNullOrBlank() -> {
.orElseThrow { NotFoundException("Раздел не найден") } val anchor =
!newSectionTitle.isNullOrBlank() -> { sections
val chapter: ContentChapter = when { .findById(sectionId)
chapterId != null -> chapters.findById(chapterId) .orElseThrow { NotFoundException("Раздел не найден") }
.orElseThrow { NotFoundException("Глава не найдена") } val node = createSection(anchor.chapterId, newSectionTitle, null)
!newChapterTitle.isNullOrBlank() -> { sections
val node = createChapter(newChapterTitle, null) .findById(UUID.fromString(node.id))
chapters.findById(UUID.fromString(node.id)) .orElseThrow { NotFoundException("Раздел не найден") }
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
} }
val node = createSection(chapter.id, newSectionTitle, null) sectionId != null ->
sections.findById(UUID.fromString(node.id)) sections
.orElseThrow { NotFoundException("Раздел не найден") } .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("Укажите раздел")
} }
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, title, bodyMd) return createArticle(targetSection.id, title, bodyMd)
} }
@Transactional @Transactional
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView { fun createArticle(
sectionId: UUID,
title: String,
bodyMd: String,
): ContentArticleView {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet()) val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0 val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a = articles.save( val a =
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1), articles.save(
) ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
)
return ContentArticleView( return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title, id = a.id.toString(),
sectionSlug = sec.slug, sectionTitle = sec.title, chapterSlug = ch.slug,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
) )
} }
@Transactional @Transactional
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView { fun moveArticle(
articleId: UUID,
newSectionId: UUID,
): ContentArticleView {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
@@ -418,13 +523,21 @@ class ContentService(
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1 a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
articles.save(a) articles.save(a)
return ContentArticleView( return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title, id = a.id.toString(),
sectionSlug = sec.slug, sectionTitle = sec.title, chapterSlug = ch.slug,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, 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 { private fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item" val base = slugify(title) ?: "item"
if (base !in taken) return base if (base !in taken) return base
var i = 2 var i = 2
@@ -438,7 +551,11 @@ class ContentService(
* картинки в assets/. * картинки в assets/.
*/ */
@Transactional @Transactional
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult { fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) { if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив") throw BadRequestException("Ожидается .zip архив")
} }
@@ -446,9 +563,10 @@ class ContentService(
if (files.isEmpty()) throw BadRequestException("Архив пуст") if (files.isEmpty()) throw BadRequestException("Архив пуст")
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") } val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
val contentFiles = files.filterKeys { k -> val contentFiles =
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md") files.filterKeys { k ->
} (k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
}
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами") if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
val linkRewrite = mutableMapOf<String, String>() val linkRewrite = mutableMapOf<String, String>()
@@ -459,36 +577,38 @@ class ContentService(
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}" linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
} }
val parsed = contentFiles.map { (path, data) -> val parsed =
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md") contentFiles
val parts = clean.split('/') .map { (path, data) ->
if (parts.size < 3) { val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)") val parts = clean.split('/')
} if (parts.size < 3) {
val chapterDir = parts[0] throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
val sectionDir = parts[1] }
val fileName = parts.drop(2).joinToString("-") val chapterDir = parts[0]
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8)) val sectionDir = parts[1]
ParsedArticle( val fileName = parts.drop(2).joinToString("-")
path = path, val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
chapterPos = positionOf(chapterDir), ParsedArticle(
chapterDir = titleFromDir(chapterDir), path = path,
sectionPos = positionOf(sectionDir), chapterPos = positionOf(chapterDir),
sectionDir = titleFromDir(sectionDir), chapterDir = titleFromDir(chapterDir),
articlePos = positionOf(fileName), sectionPos = positionOf(sectionDir),
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName), sectionDir = titleFromDir(sectionDir),
fmSlug = parsedContent.first["slug"], articlePos = positionOf(fileName),
body = parsedContent.second, fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
) fmSlug = parsedContent.first["slug"],
}.sortedWith( body = parsedContent.second,
compareBy( )
{ it.chapterPos }, }.sortedWith(
{ it.chapterDir }, compareBy(
{ it.sectionPos }, { it.chapterPos },
{ it.sectionDir }, { it.chapterDir },
{ it.articlePos }, { it.sectionPos },
), { it.sectionDir },
) { it.articlePos },
),
)
var chCount = 0 var chCount = 0
var secCount = 0 var secCount = 0
@@ -523,15 +643,21 @@ class ContentService(
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу") val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) { if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}" val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir) currentSection = (
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
)?.apply {
title = p.sectionDir title = p.sectionDir
position = p.sectionPos position = p.sectionPos
} ?: sections.save( } ?: sections.save(
ContentSection( ContentSection(
chapterId = chapter.id, chapterId = chapter.id,
title = p.sectionDir, title = p.sectionDir,
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()), slug =
uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos, position = p.sectionPos,
), ),
) )
@@ -545,16 +671,19 @@ class ContentService(
for ((from, to) in linkRewrite) { for ((from, to) in linkRewrite) {
body = body.replace(from, to) body = body.replace(from, to)
} }
val slug = p.fmSlug?.takeIf { it.isNotBlank() } val slug =
?: articleSlug(p) p.fmSlug?.takeIf { it.isNotBlank() }
?: "article-${artCount + 1}" ?: articleSlug(p)
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle) ?: "article-${artCount + 1}"
?: articles.findBySectionIdAndSlug(section.id, slug) val existing =
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply { articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
title = p.fileTitle ?: articles.findBySectionIdAndSlug(section.id, slug)
bodyMd = body val article =
position = p.articlePos (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
} title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article) articles.save(article)
seenArticles.add(article.id) seenArticles.add(article.id)
artCount += 1 artCount += 1
@@ -613,19 +742,22 @@ class ContentService(
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> { private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines() val lines = content.lines()
val end = lines.withIndex() val end =
.firstOrNull { it.index != 0 && it.value.trim() == "---" } lines
?.index ?: return emptyMap<String, String>() to content .withIndex()
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
?.index ?: return emptyMap<String, String>() to content
val fm = mutableMapOf<String, String>() val fm = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) { for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2) val parts = line.split(":", limit = 2)
if (parts.size == 2) { if (parts.size == 2) {
val v = parts[1].trim() val v = parts[1].trim()
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') { val unquoted =
v.substring(1, v.length - 1) if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
} else { v.substring(1, v.length - 1)
v } else {
} v
}
fm[parts[0].trim()] = unquoted fm[parts[0].trim()] = unquoted
} }
} }
@@ -638,29 +770,61 @@ class ContentService(
} }
private fun titleFromDir(dir: String): String { private fun titleFromDir(dir: String): String {
val raw = dir.substringAfterLast('/') val raw =
.replace(Regex("^\\d{1,3}[-_]"), "") dir
.replace('_', ' ') .substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir } return raw.ifBlank { dir }
} }
private fun slugify(value: String): String? { private fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) } val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug = translit val slug =
.replace(Regex("\\s+"), "-") translit
.replace(Regex("[^a-z0-9-]"), "") .replace(Regex("\\s+"), "-")
.replace(Regex("-+"), "-") .replace(Regex("[^a-z0-9-]"), "")
.trim('-') .replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null } return slug.ifBlank { null }
} }
private companion object { private companion object {
val TRANSLIT = mapOf( val TRANSLIT =
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e", mapOf(
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m", "а" to "a",
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u", "б" to "b",
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "", "в" to "v",
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya", "г" to "g",
) "д" to "d",
"е" to "e",
"ё" to "e",
"ж" to "zh",
"з" to "z",
"и" to "i",
"й" to "y",
"к" to "k",
"л" to "l",
"м" to "m",
"н" to "n",
"о" to "o",
"п" to "p",
"р" to "r",
"с" to "s",
"т" to "t",
"у" to "u",
"ф" to "f",
"х" to "h",
"ц" to "c",
"ч" to "ch",
"ш" to "sh",
"щ" to "sch",
"ъ" to "",
"ы" to "y",
"ь" to "",
"э" to "e",
"ю" to "yu",
"я" to "ya",
)
} }
} }

View File

@@ -23,9 +23,14 @@ 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,
) {
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,42 +75,65 @@ 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 глав, разделов и статей). */ /** Новое расположение материалов (drag & drop глав, разделов и статей). */
@PostMapping("/reorder") @PostMapping("/reorder")
fun reorder(@RequestBody body: ReorderTree) { fun reorder(
@RequestBody body: ReorderTree,
) {
contentService.reorder(body) contentService.reorder(body)
} }
/** Порядок разделов внутри главы (drag & drop на странице главы). */ /** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/sections/reorder") @PostMapping("/chapters/{id}/sections/reorder")
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) { fun reorderSections(
@PathVariable id: UUID,
@RequestBody body: ReorderSections,
) {
contentService.reorderSections(id, body.sections) contentService.reorderSections(id, body.sections)
} }
/** Порядок статей внутри раздела (drag & drop на странице раздела). */ /** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/articles/reorder") @PostMapping("/sections/{id}/articles/reorder")
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) { fun reorderArticles(
@PathVariable id: UUID,
@RequestBody body: ReorderArticles,
) {
contentService.reorderArticles(id, body.articles) contentService.reorderArticles(id, body.articles)
} }
@@ -105,25 +150,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,
) = contentService.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,
) = contentService.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}") @DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> { fun deleteChapter(
@PathVariable id: UUID,
): ResponseEntity<Void> {
contentService.deleteChapter(id) contentService.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,
) = contentService.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,
) = contentService.renameSection(id, body.title)
@DeleteMapping("/sections/{id}") @DeleteMapping("/sections/{id}")
fun deleteSection( fun deleteSection(
@@ -139,7 +194,9 @@ class ContentAdminController(private val contentService: ContentService) {
} }
@PostMapping("/articles") @PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced( fun createArticle(
@RequestBody body: ArticleCreatePayload,
) = contentService.createArticlePlaced(
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) },
@@ -149,16 +206,23 @@ class ContentAdminController(private val contentService: ContentService) {
) )
@PutMapping("/articles/{id}") @PutMapping("/articles/{id}")
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) { fun update(
@PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
) {
contentService.updateArticle(id, body.title, body.bodyMd) contentService.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,
) = contentService.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}") @DeleteMapping("/articles/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> { fun delete(
@PathVariable id: UUID,
): ResponseEntity<Void> {
contentService.deleteArticle(id) contentService.deleteArticle(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }

View File

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

View File

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

View File

@@ -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

View File

@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
*/ */
@Component @Component
class LabImportParser { class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory()) private val yaml =
.registerModule(KotlinModule.Builder().build()) ObjectMapper(YAMLFactory())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) .registerModule(KotlinModule.Builder().build())
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(filename: String, bytes: ByteArray): LabImportBundle { fun parse(
filename: String,
bytes: ByteArray,
): LabImportBundle {
if (!filename.lowercase().endsWith(".zip")) { if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)") throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
} }
@@ -40,8 +44,9 @@ class LabImportParser {
} }
} }
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") } val metaEntry =
?: throw BadRequestException("В архиве не найден lab.yaml") entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java) val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" } val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
@@ -71,7 +76,11 @@ class LabImportParser {
return LabImportBundle(meta, readmeMd, variants, assets) return LabImportBundle(meta, readmeMd, variants, assets)
} }
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? { private fun parseVariantFile(
path: String,
content: String,
levels: List<LabLevelMeta>,
): VariantMeta? {
val levelMap = levels.associateBy { it.code } val levelMap = levels.associateBy { it.code }
val lines = content.lines() val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" } val firstSep = lines.indexOfFirst { it.trim() == "---" }
@@ -111,9 +120,10 @@ class LabImportParser {
value value
} }
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) { private fun normalizeLevel(s: String): String =
"main", "basic", "обычная", "основная" -> "basic" when (s.lowercase().trim()) {
"advanced", "повышенная", "повышенной", "hard" -> "advanced" "main", "basic", "обычная", "основная" -> "basic"
else -> s.lowercase().trim() "advanced", "повышенная", "повышенной", "hard" -> "advanced"
} else -> s.lowercase().trim()
}
} }

View File

@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -28,12 +28,15 @@ class LabService(
private val semesters: SemesterRepository, private val semesters: SemesterRepository,
private val parser: LabImportParser, private val parser: LabImportParser,
) { ) {
@Transactional @Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult { fun importFromFile(
filename: String,
bytes: ByteArray,
): LabImportResult {
val bundle = parser.parse(filename, bytes) val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue() val semester =
?: throw BadRequestException("Нет активного семестра. Создайте семестр.") semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) { if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
throw ConflictException( throw ConflictException(
@@ -41,46 +44,56 @@ class LabService(
) )
} }
val lab = labs.save(Lab( val lab =
number = bundle.meta.number, labs.save(
title = bundle.meta.title, Lab(
semester = semester, number = bundle.meta.number,
descriptionMd = bundle.descriptionMd, title = bundle.meta.title,
pointsMax = bundle.meta.pointsMax, semester = semester,
status = bundle.meta.status, descriptionMd = bundle.descriptionMd,
deadlineAt = parseInstant(bundle.meta.deadlineAt), pointsMax = bundle.meta.pointsMax,
defenseAt = parseInstant(bundle.meta.defenseAt), status = bundle.meta.status,
)) deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
val levelMetas = bundle.meta.levels.ifEmpty { ),
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
) )
}
val levelEntities = levelMetas.map { meta -> val levelMetas =
labLevels.save(LabLevel( bundle.meta.levels.ifEmpty {
lab = lab, listOf(
code = meta.code, LabLevelMeta("basic", "Основная группа", position = 1),
title = meta.title, LabLevelMeta("advanced", "Повышенная сложность", position = 2),
description = meta.description.orEmpty(), )
position = meta.position ?: 0, }
pointsMax = meta.pointsMax, val levelEntities =
)) levelMetas.map { meta ->
} labLevels.save(
LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
),
)
}
val levelByCode = levelEntities.associateBy { it.code } val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta -> bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level] val level =
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}") levelByCode[vmeta.level]
variantsRepo.save(Variant( ?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
lab = lab, variantsRepo.save(
labLevel = level, Variant(
number = vmeta.number, lab = lab,
title = vmeta.title, labLevel = level,
statementMd = vmeta.statement, number = vmeta.number,
)) title = vmeta.title,
statementMd = vmeta.statement,
),
)
} }
return LabImportResult( return LabImportResult(
@@ -122,25 +135,29 @@ class LabService(
deadlineAt = lab.deadlineAt?.toString(), deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(), defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax, pointsMax = lab.pointsMax,
levels = levels.map { level -> levels =
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой). levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id) // Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
.filter { it.labLevel?.id == level.id } val vs =
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number })) variantsRepo
LabLevelDetail( .findByLabIdOrderByNumber(lab.id)
id = level.id.toString(), .filter { it.labLevel?.id == level.id }
code = level.code, .sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
title = level.title, LabLevelDetail(
variants = vs.map { v -> id = level.id.toString(),
VariantDetail( code = level.code,
id = v.id.toString(), title = level.title,
number = v.number, variants =
title = v.title, vs.map { v ->
statementMd = v.statementMd, VariantDetail(
) id = v.id.toString(),
}, number = v.number,
) title = v.title,
}, statementMd = v.statementMd,
)
},
)
},
) )
} }
@@ -153,7 +170,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,42 +187,62 @@ class LabService(
} }
@Transactional @Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail { fun updateVariant(
val variant = variantsRepo.findById(variantId) labId: UUID,
.orElseThrow { NotFoundException("Вариант не найден") } variantId: UUID,
request: VariantSaveRequest,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым") if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim() variant.number = request.number.trim()
variant.title = request.title.orEmpty().trim() variant.title = request.title.orEmpty().trim()
variant.statementMd = request.statementMd.orEmpty() variant.statementMd = request.statementMd.orEmpty()
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId -> request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId)) variant.labLevel =
.orElseThrow { BadRequestException("Уровень не найден") } labLevels
.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
} }
variantsRepo.save(variant) variantsRepo.save(variant)
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail { fun addVariant(
labId: UUID,
request: VariantSaveRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым") if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
val level = labLevels.findById(UUID.fromString(request.labLevelId)) val level =
.orElseThrow { BadRequestException("Уровень не найден") } labLevels
variantsRepo.save(Variant( .findById(UUID.fromString(request.labLevelId))
lab = lab, .orElseThrow { BadRequestException("Уровень не найден") }
labLevel = level, variantsRepo.save(
number = request.number.trim(), Variant(
title = request.title.orEmpty().trim(), lab = lab,
statementMd = request.statementMd.orEmpty(), labLevel = level,
)) number = request.number.trim(),
title = request.title.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
),
)
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail { fun deleteVariant(
val variant = variantsRepo.findById(variantId) labId: UUID,
.orElseThrow { NotFoundException("Вариант не найден") } variantId: UUID,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден") if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant) variantsRepo.delete(variant)
return detail(labId) return detail(labId)
@@ -216,21 +256,23 @@ class LabService(
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) { if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
} }
val lab = labs.save( val lab =
Lab( labs.save(
number = request.number ?: 0, Lab(
title = request.title.orEmpty().trim(), number = request.number ?: 0,
semester = semester, title = request.title.orEmpty().trim(),
descriptionMd = request.descriptionMd.orEmpty(), semester = semester,
deadlineAt = parseInstant(request.deadlineAt), descriptionMd = request.descriptionMd.orEmpty(),
defenseAt = parseInstant(request.defenseAt), deadlineAt = parseInstant(request.deadlineAt),
pointsMax = request.pointsMax ?: BigDecimal(5), defenseAt = parseInstant(request.defenseAt),
status = request.status ?: "draft", pointsMax = request.pointsMax ?: BigDecimal(5),
), status = request.status ?: "draft",
) ),
val levelMetas = request.levels.ifEmpty { )
listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) val levelMetas =
} request.levels.ifEmpty {
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
}
levelMetas.forEachIndexed { index, meta -> levelMetas.forEachIndexed { index, meta ->
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым") if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
labLevels.save( labLevels.save(
@@ -247,7 +289,11 @@ class LabService(
} }
@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 +303,10 @@ class LabService(
} }
@Transactional @Transactional
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail { fun deleteLevel(
labId: UUID,
levelId: UUID,
): LabDetail {
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") } val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден") if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) { if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
@@ -267,8 +316,11 @@ class LabService(
return detail(labId) return detail(labId)
} }
@Transactional @Transactional
fun addLevel(labId: UUID, request: LevelSaveRequest) { fun addLevel(
labId: UUID,
request: LevelSaveRequest,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым") if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
val existing = labLevels.findByLabIdOrderByPosition(labId) val existing = labLevels.findByLabIdOrderByPosition(labId)
@@ -290,15 +342,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,10 @@ data class ParsedGroup(
@Component @Component
class StudentImportParser { class StudentImportParser {
fun parse(
fun parse(filename: String, bytes: ByteArray): ParsedGroup { filename: String,
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase() val lower = filename.lowercase()
return when { return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes)) lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
@@ -65,7 +67,10 @@ class StudentImportParser {
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';' return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
} }
private fun splitCsvLine(line: String, delimiter: Char): List<String> { private fun splitCsvLine(
line: String,
delimiter: Char,
): List<String> {
val result = mutableListOf<String>() val result = mutableListOf<String>()
val current = StringBuilder() val current = StringBuilder()
var inQuotes = false var inQuotes = false
@@ -83,7 +88,10 @@ 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 = 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("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
@@ -92,25 +100,26 @@ class StudentImportParser {
if (columns["studentNo"] == null) { if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»") throw BadRequestException("Не найден столбец «Студенческий номер»")
} }
val students = rows.drop(headerIndex + 1).mapNotNull { row -> val students =
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim() rows.drop(headerIndex + 1).mapNotNull { row ->
val studentNo = normalizeNumber(cell("studentNo")) fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val lastName = cell("lastName") val studentNo = normalizeNumber(cell("studentNo"))
if (studentNo.isBlank() && lastName.isBlank()) { val lastName = cell("lastName")
return@mapNotNull null if (studentNo.isBlank() && lastName.isBlank()) {
return@mapNotNull null
}
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
} }
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
}
if (students.isEmpty()) { if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента") throw BadRequestException("В таблице не найдено ни одного студента")
} }
@@ -119,15 +128,21 @@ class StudentImportParser {
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 +157,12 @@ class StudentImportParser {
return result return result
} }
private fun normalizeNumber(value: String): String = private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
if (value.endsWith(".0")) value.dropLast(2) else value
private fun groupNameFrom(filename: String, rows: List<List<String>>): String { private fun groupNameFrom(
filename: String,
rows: List<List<String>>,
): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*") val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) { for (row in rows.take(12)) {
for (cell in row) { for (cell in row) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ data class DefenseDto(
class DefenseService( class DefenseService(
private val sessions: DefenseSessionRepository, private val sessions: DefenseSessionRepository,
) { ) {
companion object { companion object {
val DURATION: Duration = Duration.ofMinutes(30) val DURATION: Duration = Duration.ofMinutes(30)
private val random = SecureRandom() private val random = SecureRandom()
@@ -55,7 +54,11 @@ class DefenseService(
} }
@Transactional @Transactional
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto { fun assign(
studentId: UUID,
labId: UUID,
assignmentMd: String,
): DefenseDto {
val existing = sessions.findByStudentIdAndLabId(studentId, labId) val existing = sessions.findByStudentIdAndLabId(studentId, labId)
val s = existing ?: DefenseSession(studentId = studentId, labId = labId) val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
// смена задания сбрасывает активную попытку защиты // смена задания сбрасывает активную попытку защиты
@@ -106,10 +109,14 @@ 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 =
?: throw BadRequestException("Код не найден") sessions.findByCode(code.trim())
?: throw BadRequestException("Код не найден")
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту") if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата") if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
s.status = DefenseStatus.started.name s.status = DefenseStatus.started.name
@@ -118,7 +125,10 @@ class DefenseService(
} }
@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 +145,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 +168,18 @@ class DefenseService(
} }
private fun toDto(s: DefenseSession): DefenseDto { private fun toDto(s: DefenseSession): DefenseDto {
val left = if (s.status == DefenseStatus.started.name) { val left =
val started = s.startedAt ?: Instant.now() if (s.status == DefenseStatus.started.name) {
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0) val started = s.startedAt ?: Instant.now()
remain val remain =
} else null java.time.Duration
.between(Instant.now(), started.plus(DURATION))
.seconds
.coerceAtLeast(0)
remain
} else {
null
}
return DefenseDto( return DefenseDto(
id = s.id.toString(), id = s.id.toString(),
studentId = s.studentId.toString(), studentId = s.studentId.toString(),

View File

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

View File

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

View File

@@ -7,7 +7,14 @@ 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.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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -21,9 +28,10 @@ class ProgressController(
private val submissions: SubmissionService, private val submissions: SubmissionService,
private val academic: AcademicClient, 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 +39,9 @@ class ProgressController(
} }
@GetMapping("/me/labs") @GetMapping("/me/labs")
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> { fun meLabs(
return listOf() @AuthenticationPrincipal jwt: Jwt,
} ): List<Map<String, Any>> = listOf()
@PostMapping("/levels") @PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -44,7 +52,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,7 +87,9 @@ 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 = service.setGrade(body)
@GetMapping("/grades") @GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -85,19 +97,26 @@ class ProgressController(
@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(
@@ -111,7 +130,9 @@ class ProgressController(
@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,
): List<CommentOut> = service.suggestComments(q)
@PostMapping("/me/submissions/{labId}") @PostMapping("/me/submissions/{labId}")
fun submit( fun submit(
@@ -131,7 +152,10 @@ class ProgressController(
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId) ): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download") @GetMapping("/me/submissions/{id}/download")
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> { fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject) val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false) val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name) return attachment(bytes, name)
@@ -139,22 +163,29 @@ class ProgressController(
@GetMapping("/submissions") @GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> = fun labSubmissions(
submissions.latestPerStudent(labId) @RequestParam labId: UUID,
): List<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download") @GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> { fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true) val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name) return attachment(bytes, name)
} }
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> = private fun attachment(
ResponseEntity.ok() bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header( .header(
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}", "attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
) ).contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes) .body(bytes)
} }

View File

@@ -8,13 +8,13 @@ import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey 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.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository 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.math.BigDecimal
import java.util.UUID import java.util.UUID
@@ -28,9 +28,11 @@ class ProgressService(
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient, private val identity: IdentityClient,
) { ) {
@Transactional @Transactional
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse { fun setLevel(
request: LevelAssignmentRequest,
token: String,
): LevelAssignmentResponse {
val sid = UUID.fromString(request.studentId) val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId) val lid = UUID.fromString(request.labId)
val key = StudentLabLevelKey(sid, lid) val key = StudentLabLevelKey(sid, lid)
@@ -46,20 +48,27 @@ class ProgressService(
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId } val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
if (level != null && level.variants.isNotEmpty()) { if (level != null && level.variants.isNotEmpty()) {
val assignment = assignments.findByStudentIdAndLabId(sid, lid) val assignment = assignments.findByStudentIdAndLabId(sid, lid)
val currentInLevel = assignment?.variantId?.toString() val currentInLevel =
?.let { vid -> level.variants.any { it.id == vid } } == true assignment
?.variantId
?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
if (!currentInLevel) { if (!currentInLevel) {
val used = assignments.findByLabId(lid) val used =
.map { it.variantId.toString() } assignments
.filter { it != assignment?.variantId?.toString() } .findByLabId(lid)
.toMutableSet() .map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val pool = level.variants.map { it.id } val pool = level.variants.map { it.id }
val chosen = pool.filter { it !in used }.shuffled().firstOrNull() val chosen =
?: pool.shuffled().first() pool.filter { it !in used }.shuffled().firstOrNull()
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply { ?: pool.shuffled().first()
variantId = UUID.fromString(chosen) val target =
manual = false (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
} variantId = UUID.fromString(chosen)
manual = false
}
assignments.save(target) assignments.save(target)
} }
} }
@@ -72,23 +81,34 @@ 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 =
request.labIds
.orEmpty()
.ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная") 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 groupFilter =
request.groupIds
.orEmpty()
.filter { it.isNotBlank() }
.toSet()
val studentIds = mutableListOf<UUID>() val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) { for (g in identity.listGroups(token)) {
val gid = g["id"] as String val gid = g["id"] as String
@@ -111,14 +131,16 @@ class ProgressService(
// force сбрасывает авто-назначения только у выбранных студентов; // force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть. // закреплённые вручную варианты остаются как есть.
if (force) { if (force) {
assignments.findByLabId(labId) assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual } .filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) } .forEach { assignments.delete(it) }
} }
val kept = mutableMapOf<UUID, VariantAssignment>() val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents } assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments.findByLabId(labId) assignments
.findByLabId(labId)
.filter { it.studentId in allStudents } .filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it } .forEach { kept[it.studentId] = it }
@@ -132,17 +154,23 @@ class ProgressService(
val defaultLevel = lab.levels.firstOrNull()?.id val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) { if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage = assignments.findByLabId(labId) val usage =
.groupingBy { it.variantId.toString() } assignments
.eachCount() .findByLabId(labId)
.toMutableMap() .groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
for (sid in toAssign.shuffled()) { for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() val levelId =
?: defaultLevel levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
val pool = levelId?.let { poolsByLevel[it] }.orEmpty() ?: defaultLevel
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() } val pool =
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() } levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
if (pool.isEmpty()) continue if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 } val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random() val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
@@ -174,23 +202,26 @@ class ProgressService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
assignments.findByLabId(labId)
@Transactional @Transactional
fun assign(request: AssignRequest): VariantAssignment { fun assign(request: AssignRequest): VariantAssignment {
val sid = UUID.fromString(request.studentId) val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId) val lid = UUID.fromString(request.labId)
val existing = assignments.findByStudentIdAndLabId(sid, lid) val existing = assignments.findByStudentIdAndLabId(sid, lid)
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply { val entity =
variantId = UUID.fromString(request.variantId) (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
manual = true variantId = UUID.fromString(request.variantId)
} manual = true
}
return assignments.save(entity) return assignments.save(entity)
} }
@Transactional @Transactional
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse { fun assignWithInfo(
request: AssignRequest,
token: String,
): VariantAssignmentResponse {
assign(request) assign(request)
val lab = academic.getLab(request.labId, token) val lab = academic.getLab(request.labId, token)
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId } val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
@@ -204,7 +235,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,8 +254,7 @@ 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 @Transactional
fun setGrade(request: GradeSetRequest): GradeResponse { fun setGrade(request: GradeSetRequest): GradeResponse {
@@ -235,11 +268,19 @@ class ProgressService(
grade.score = request.score grade.score = request.score
grades.save(grade) grades.save(grade)
replaceComments(sid, lid, request.comments) replaceComments(sid, lid, request.comments)
return GradeResponse(request.studentId, request.labId, request.score, return GradeResponse(
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) }) 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>) { private fun replaceComments(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet() 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) } val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return if (existing.toSet() == incoming.toSet()) return
@@ -247,23 +288,33 @@ class ProgressService(
for ((text, tone) in incoming) { for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone)) gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text) val t = templates.findByText(text)
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1)) if (t == null) {
else { t.useCount += 1; t.tone = tone; templates.save(t) } 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()) { private fun normalizeTone(tone: String): String =
"positive", "положительный", "позитивный", "pos" -> "positive" when (tone.lowercase()) {
"negative", "негативный", "отрицательный", "neg" -> "negative" "positive", "положительный", "позитивный", "pos" -> "positive"
else -> "neutral" "negative", "негативный", "отрицательный", "neg" -> "negative"
} else -> "neutral"
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid) fun commentsOf(
sid: UUID,
lid: UUID,
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> = fun suggestComments(query: String): List<CommentOut> =
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) } .map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -271,8 +322,10 @@ class ProgressService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> { fun listGrades(): List<GradeResponse> {
val byGrade = gradeComments.findAll() val byGrade =
.groupBy { it.studentId to it.labId } gradeComments
.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map { return grades.findAllBy().map {
GradeResponse( GradeResponse(
studentId = it.studentId.toString(), studentId = it.studentId.toString(),
@@ -284,12 +337,19 @@ class ProgressService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { fun getStudentAssignment(
studentId: UUID,
labId: UUID,
token: String,
): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null 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() } }

View File

@@ -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

View File

@@ -18,7 +18,6 @@ class SubmissionService(
private val storage: SubmissionStorage, private val storage: SubmissionStorage,
private val academic: AcademicClient, private val academic: AcademicClient,
) { ) {
@Transactional @Transactional
fun submit( fun submit(
studentId: UUID, studentId: UUID,
@@ -29,8 +28,9 @@ class SubmissionService(
if (files.isEmpty() || files.all { it.isEmpty }) { if (files.isEmpty() || files.all { it.isEmpty }) {
throw BadRequestException("Приложите файлы или архив") throw BadRequestException("Приложите файлы или архив")
} }
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull() val lab =
?: throw NotFoundException("Лабораторная не найдена") runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() } val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) { if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк") throw BadRequestException("Срок сдачи истёк")
@@ -52,12 +52,22 @@ class SubmissionService(
} }
@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("Нет доступа к этой сдаче")
@@ -81,23 +91,29 @@ class SubmissionService(
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */ /** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt") private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
private fun looksLikeProjectFile(name: String, bytes: ByteArray): Boolean { private fun looksLikeProjectFile(
name: String,
bytes: ByteArray,
): Boolean {
val ext = name.substringAfterLast('.', "").lowercase() val ext = name.substringAfterLast('.', "").lowercase()
if (ext !in sourceExtensions) return false if (ext !in sourceExtensions) return false
val head = String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8) val head =
.removePrefix("\uFEFF") String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
.trimStart() .removePrefix("\uFEFF")
.lowercase() .trimStart()
.lowercase()
return head.startsWith("<?xml") || return head.startsWith("<?xml") ||
head.startsWith("<solution") || head.startsWith("<solution") ||
head.startsWith("<project") || head.startsWith("<project") ||
head.startsWith("<configurations") head.startsWith("<configurations")
} }
private fun filesOf(s: Submission): List<String> = private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
private fun toDto(s: Submission, files: List<String>): SubmissionDto = private fun toDto(
s: Submission,
files: List<String>,
): SubmissionDto =
SubmissionDto( SubmissionDto(
id = s.id.toString(), id = s.id.toString(),
seq = s.seq, seq = s.seq,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -35,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 = "",

View File

@@ -2,7 +2,6 @@ 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.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -24,7 +23,6 @@ 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(session: HttpSession): String = "redirect:/learn"
@@ -58,7 +56,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)
} }
@@ -95,17 +96,18 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.setContentPublished(token(session), kind, id, published) try {
redirect.addFlashAttribute( api.setContentPublished(token(session), kind, id, published)
"notice", redirect.addFlashAttribute(
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", "notice",
) if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
redirectBack(back, "redirect:/learn") )
} catch (ex: ApiException) { redirectBack(back, "redirect:/learn")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/learn" redirect.addFlashAttribute("error", ex.message)
} "redirect:/learn"
}
@PostMapping("/articles/{id}/save") @PostMapping("/articles/{id}/save")
fun save( fun save(
@@ -115,14 +117,15 @@ class ContentAdminController(
@RequestParam(required = false) back: 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 {
redirect.addFlashAttribute("notice", "Статья сохранена") api.updateContentArticle(token(session), id, title, bodyMd)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Статья сохранена")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content/articles/$id/edit" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content/articles/$id/edit"
}
@PostMapping("/articles/{id}/delete") @PostMapping("/articles/{id}/delete")
fun delete( fun delete(
@@ -130,14 +133,15 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.deleteContentArticle(token(session), id) try {
redirect.addFlashAttribute("notice", "Статья удалена") api.deleteContentArticle(token(session), id)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Статья удалена")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/chapters/create") @PostMapping("/chapters/create")
fun createChapter( fun createChapter(
@@ -146,14 +150,15 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.createChapter(token(session), title.trim(), slug) try {
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") api.createChapter(token(session), title.trim(), slug)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/chapters/{id}/rename") @PostMapping("/chapters/{id}/rename")
fun renameChapter( fun renameChapter(
@@ -162,14 +167,15 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.renameChapter(token(session), id, title.trim()) try {
redirect.addFlashAttribute("notice", "Глава переименована") api.renameChapter(token(session), id, title.trim())
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Глава переименована")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/chapters/{id}/delete") @PostMapping("/chapters/{id}/delete")
fun deleteChapter( fun deleteChapter(
@@ -177,14 +183,15 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.deleteChapter(token(session), id) try {
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") api.deleteChapter(token(session), id)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/sections/create") @PostMapping("/sections/create")
fun createSection( fun createSection(
@@ -194,14 +201,15 @@ class ContentAdminController(
@RequestParam(required = false) back: 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), chapterId, title.trim(), slug)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/sections/{id}/rename") @PostMapping("/sections/{id}/rename")
fun renameSection( fun renameSection(
@@ -210,14 +218,15 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.renameSection(token(session), id, title.trim()) try {
redirect.addFlashAttribute("notice", "Раздел переименован") api.renameSection(token(session), id, title.trim())
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Раздел переименован")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
@PostMapping("/sections/{id}/delete") @PostMapping("/sections/{id}/delete")
fun deleteSection( fun deleteSection(
@@ -226,19 +235,20 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
if (moveTo.isNullOrBlank()) { try {
api.deleteSection(token(session), id) if (moveTo.isNullOrBlank()) {
redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями") api.deleteSection(token(session), id)
} else { redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями")
api.deleteSectionWithMove(token(session), id, moveTo) } else {
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён") api.deleteSectionWithMove(token(session), id, moveTo)
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
}
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
} }
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/articles/create") @PostMapping("/articles/create")
fun createArticle( fun createArticle(
@@ -251,19 +261,23 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.createArticle( try {
token(session), chapterId, sectionId, api.createArticle(
newChapterTitle?.takeIf { it.isNotBlank() }, token(session),
newSectionTitle?.takeIf { it.isNotBlank() }, chapterId,
title.trim(), bodyMd, sectionId,
) newChapterTitle?.takeIf { it.isNotBlank() },
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") newSectionTitle?.takeIf { it.isNotBlank() },
redirectBack(back, "redirect:/admin/content") title.trim(),
} catch (ex: ApiException) { bodyMd,
redirect.addFlashAttribute("error", ex.message) )
"redirect:/admin/content" redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
} redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/articles/{id}/move") @PostMapping("/articles/{id}/move")
fun moveArticle( fun moveArticle(
@@ -272,17 +286,21 @@ class ContentAdminController(
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String =
api.moveArticle(token(session), id, sectionId) try {
redirect.addFlashAttribute("notice", "Статья перемещена") api.moveArticle(token(session), id, sectionId)
redirectBack(back, "redirect:/admin/content") redirect.addFlashAttribute("notice", "Статья перемещена")
} catch (ex: ApiException) { redirectBack(back, "redirect:/admin/content")
redirect.addFlashAttribute("error", ex.message) } catch (ex: ApiException) {
"redirect:/admin/content" redirect.addFlashAttribute("error", ex.message)
} "redirect:/admin/content"
}
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */ /** Возврат туда, откуда пришли: принимаем только внутренние пути. */
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 {

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,22 @@ data class AuthResponseDto(
val mustChangePassword: Boolean = false, val mustChangePassword: Boolean = false,
) )
data class LoginPayload(val login: String, val password: String) data class LoginPayload(
val login: String,
val password: String,
)
data class SetupPayload(val login: String, val password: String, val password2: String) data class SetupPayload(
val login: String,
val password: String,
val password2: String,
)
data class ChangePasswordPayload(val old: String, val new: String, val new2: String) data class ChangePasswordPayload(
val old: String,
val new: String,
val new2: String,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDto( data class GroupDto(
@@ -32,7 +43,9 @@ data class GroupDto(
val taskDurationMin: Int = 30, val taskDurationMin: Int = 30,
) )
data class CreateGroupPayload(val name: String) data class CreateGroupPayload(
val name: String,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CredentialDto( data class CredentialDto(
@@ -77,7 +90,9 @@ data class StudentCandidateDto(
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "") data class ResetPasswordDto(
val password: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LabSummaryDto( data class LabSummaryDto(
@@ -225,7 +240,10 @@ data class GradeDto(
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto(val text: String = "", val tone: String = "neutral") data class CommentDto(
val text: String = "",
val tone: String = "neutral",
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class SubmissionDto( data class SubmissionDto(
@@ -244,7 +262,10 @@ data class GradeSetPayload(
val comments: List<CommentPayload> = emptyList(), val comments: List<CommentPayload> = emptyList(),
) )
data class CommentPayload(val text: String, val tone: String) data class CommentPayload(
val text: String,
val tone: String,
)
data class LabCreatePayload( data class LabCreatePayload(
val number: Int, val number: Int,
@@ -274,15 +295,29 @@ data class LevelRenamePayload(
val description: String = "", val description: String = "",
) )
data class TitlePayload(val title: String) data class TitlePayload(
val title: String,
)
data class ChapterCreatePayload(val title: String, val slug: String? = null) data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(val title: String, val slug: String? = null) data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
data class ArticleCreatePayload(val sectionId: String, val title: String, val bodyMd: String) data class ArticleCreatePayload(
val sectionId: String,
val title: String,
val bodyMd: String,
)
data class ArticleMovePayload(val sectionId: String) data class ArticleMovePayload(
val sectionId: String,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CreateStudentRequestDto( data class CreateStudentRequestDto(

View File

@@ -14,13 +14,17 @@ import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser import ru.ysu.lms.web.session.SessionUser
@Controller @Controller
class AuthPageController(private val api: ApiClient) { class AuthPageController(
private val api: ApiClient,
) {
@GetMapping("/") @GetMapping("/")
fun root(): String = "redirect:/learn" fun root(): String = "redirect:/learn"
@GetMapping("/login") @GetMapping("/login")
fun loginPage(session: HttpSession, model: Model): String { fun loginPage(
session: HttpSession,
model: Model,
): String {
if (currentUser(session) != null) { if (currentUser(session) != null) {
return "redirect:/" return "redirect:/"
} }
@@ -38,16 +42,17 @@ class AuthPageController(private val api: ApiClient) {
@RequestParam login: String, @RequestParam login: String,
@RequestParam password: String, @RequestParam password: String,
model: Model, model: Model,
): String = try { ): String =
val auth = api.login(login.trim(), password) try {
store(request, auth) val auth = api.login(login.trim(), password)
redirectFor(auth) store(request, auth)
} catch (ex: ApiException) { redirectFor(auth)
model.addAttribute("adminExists", api.adminExists()) } catch (ex: ApiException) {
model.addAttribute("error", ex.message) model.addAttribute("adminExists", api.adminExists())
model.addAttribute("login", login) model.addAttribute("error", ex.message)
"login" model.addAttribute("login", login)
} "login"
}
@GetMapping("/setup") @GetMapping("/setup")
fun setupPage(model: Model): String { fun setupPage(model: Model): String {
@@ -64,15 +69,16 @@ class AuthPageController(private val api: ApiClient) {
@RequestParam password: String, @RequestParam password: String,
@RequestParam password2: String, @RequestParam password2: String,
model: Model, model: Model,
): String = try { ): String =
val auth = api.setup(login.trim(), password, password2) try {
store(request, auth) val auth = api.setup(login.trim(), password, password2)
"redirect:/admin" store(request, auth)
} catch (ex: ApiException) { "redirect:/admin"
model.addAttribute("error", ex.message) } catch (ex: ApiException) {
model.addAttribute("login", login) model.addAttribute("error", ex.message)
"setup" model.addAttribute("login", login)
} "setup"
}
@GetMapping("/change-password") @GetMapping("/change-password")
fun changePasswordPage(): String = "change_password" fun changePasswordPage(): String = "change_password"
@@ -103,10 +109,12 @@ class AuthPageController(private val api: ApiClient) {
return "redirect:/login" return "redirect:/login"
} }
private fun currentUser(session: HttpSession): SessionUser? = private fun currentUser(session: HttpSession): SessionUser? = session.getAttribute(SessionKeys.USER) as? SessionUser
session.getAttribute(SessionKeys.USER) as? SessionUser
private fun store(request: HttpServletRequest, auth: AuthResponseDto) { private fun store(
request: HttpServletRequest,
auth: AuthResponseDto,
) {
// Защита от session fixation: новая сессия после аутентификации. // Защита от session fixation: новая сессия после аутентификации.
request.getSession(false)?.invalidate() request.getSession(false)?.invalidate()
val session = request.getSession(true) val session = request.getSession(true)
@@ -123,9 +131,10 @@ class AuthPageController(private val api: ApiClient) {
) )
} }
private fun redirectFor(auth: AuthResponseDto): String = when { private fun redirectFor(auth: AuthResponseDto): String =
auth.mustChangePassword -> "redirect:/change-password" when {
auth.role == "ADMIN" -> "redirect:/admin" auth.mustChangePassword -> "redirect:/change-password"
else -> "redirect:/student" auth.role == "ADMIN" -> "redirect:/admin"
} else -> "redirect:/student"
}
} }

View File

@@ -12,7 +12,6 @@ import java.util.UUID
*/ */
@Component @Component
class CsrfInterceptor : HandlerInterceptor { class CsrfInterceptor : HandlerInterceptor {
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,

View File

@@ -19,25 +19,31 @@ class WebConfig(
private val csrfInterceptor: CsrfInterceptor, private val csrfInterceptor: CsrfInterceptor,
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor, private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**") registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry.addInterceptor(mustChangePasswordInterceptor) registry
.addInterceptor(mustChangePasswordInterceptor)
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns( .excludePathPatterns(
"/change-password", "/logout", "/login", "/css/**", "/js/**", "/favicon.svg", "/error", "/change-password",
"/logout",
"/login",
"/css/**",
"/js/**",
"/favicon.svg",
"/error",
) )
registry.addInterceptor(authInterceptor) registry
.addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/change-password") .addPathPatterns("/admin/**", "/student/**", "/change-password")
registry.addInterceptor(adminInterceptor) registry
.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**") .addPathPatterns("/admin/**")
} }
} }
@ControllerAdvice @ControllerAdvice
class GlobalModelAdvice { class GlobalModelAdvice {
@ModelAttribute("currentUser") @ModelAttribute("currentUser")
fun currentUser(request: HttpServletRequest): SessionUser? = fun currentUser(request: HttpServletRequest): SessionUser? = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
} }

View File

@@ -8,9 +8,12 @@ import ru.ysu.lms.web.api.ApiException
@ControllerAdvice @ControllerAdvice
class WebErrorAdvice { class WebErrorAdvice {
@ExceptionHandler(ApiException::class) @ExceptionHandler(ApiException::class)
fun handleApiError(ex: ApiException, response: HttpServletResponse, model: Model): String { fun handleApiError(
ex: ApiException,
response: HttpServletResponse,
model: Model,
): String {
response.status = ex.status response.status = ex.status
model.addAttribute("status", ex.status) model.addAttribute("status", ex.status)
model.addAttribute("message", ex.message) model.addAttribute("message", ex.message)

View File

@@ -12,14 +12,19 @@ import ru.ysu.lms.web.api.ApiClient
* отдаются web, а src картинок указывает на этот же хост. * отдаются web, а src картинок указывает на этот же хост.
*/ */
@RestController @RestController
class ContentAssetProxy(private val api: ApiClient) { class ContentAssetProxy(
private val api: ApiClient,
) {
@GetMapping("/api/v1/content/assets/{name}") @GetMapping("/api/v1/content/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<ByteArray> { fun asset(
@PathVariable name: String,
): ResponseEntity<ByteArray> {
val asset = api.contentAsset(name) val asset = api.contentAsset(name)
val media = runCatching { MediaType.parseMediaType(asset.contentType) } val media =
.getOrElse { MediaType.APPLICATION_OCTET_STREAM } runCatching { MediaType.parseMediaType(asset.contentType) }
return ResponseEntity.ok() .getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity
.ok()
.contentType(media) .contentType(media)
.body(asset.bytes) .body(asset.bytes)
} }

View File

@@ -28,12 +28,9 @@ class LearnController(
private val api: ApiClient, private val api: ApiClient,
private val markdown: MarkdownRenderer, private val markdown: MarkdownRenderer,
) { ) {
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
private fun token(session: HttpSession): String? = private fun isAdmin(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
session.getAttribute(SessionKeys.TOKEN) as? String
private fun isAdmin(session: HttpSession): Boolean =
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> = private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> =
tree.chapters.flatMap { chapter -> tree.chapters.flatMap { chapter ->
@@ -47,7 +44,10 @@ class LearnController(
} }
@GetMapping("/learn") @GetMapping("/learn")
fun index(session: HttpSession, model: Model): String { fun index(
session: HttpSession,
model: Model,
): String {
val t = token(session) val t = token(session)
val admin = isAdmin(session) val admin = isAdmin(session)
val tree = api.contentTree(t, all = admin) val tree = api.contentTree(t, all = admin)
@@ -107,27 +107,33 @@ class LearnController(
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn" val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
if (sec.articles.none { it.slug == article }) return "redirect:/learn" if (sec.articles.none { it.slug == article }) return "redirect:/learn"
val a: ContentArticleDto = try { val a: ContentArticleDto =
api.contentArticle(token(session), chapter, section, article, all = admin) try {
} catch (ex: ApiException) { api.contentArticle(token(session), chapter, section, article, all = admin)
return "redirect:/learn" } catch (ex: ApiException) {
} return "redirect:/learn"
}
// Плоский список всех статей по порядку — для навигации «назад/далее» через разделы. // Плоский список всех статей по порядку — для навигации «назад/далее» через разделы.
val flat = tree.chapters.flatMap { c -> val flat =
c.sections.flatMap { s -> tree.chapters.flatMap { c ->
s.articles.map { art -> c.sections.flatMap { s ->
LearnArticleLink( s.articles.map { art ->
chapterSlug = c.slug, chapterTitle = c.title, LearnArticleLink(
sectionSlug = s.slug, sectionTitle = s.title, chapterSlug = c.slug,
slug = art.slug, title = art.title, chapterTitle = c.title,
) sectionSlug = s.slug,
sectionTitle = s.title,
slug = art.slug,
title = art.title,
)
}
} }
} }
} val index =
val index = flat.indexOfFirst { flat.indexOfFirst {
it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article
} }
model.addAttribute("tree", tree) model.addAttribute("tree", tree)
model.addAttribute("article", a.toView(markdown.render(a.bodyMd))) model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
model.addAttribute("prevArticle", flat.getOrNull(index - 1)) model.addAttribute("prevArticle", flat.getOrNull(index - 1))
@@ -151,15 +157,16 @@ data class LearnArticleView(
val published: Boolean, val published: Boolean,
) )
fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView( fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView =
id = id, LearnArticleView(
chapterSlug = chapterSlug, id = id,
chapterTitle = chapterTitle, chapterSlug = chapterSlug,
sectionSlug = sectionSlug, chapterTitle = chapterTitle,
sectionTitle = sectionTitle, sectionSlug = sectionSlug,
slug = slug, sectionTitle = sectionTitle,
title = title, slug = slug,
bodyHtml = bodyHtml, title = title,
bodyMd = bodyMd, bodyHtml = bodyHtml,
published = published, bodyMd = bodyMd,
) published = published,
)

View File

@@ -7,7 +7,6 @@ import org.springframework.web.servlet.HandlerInterceptor
@Component @Component
class AdminInterceptor : HandlerInterceptor { class AdminInterceptor : HandlerInterceptor {
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,

View File

@@ -7,7 +7,6 @@ import org.springframework.web.servlet.HandlerInterceptor
@Component @Component
class AuthInterceptor : HandlerInterceptor { class AuthInterceptor : HandlerInterceptor {
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,

View File

@@ -12,14 +12,14 @@ import org.springframework.web.servlet.HandlerInterceptor
*/ */
@Component @Component
class MustChangePasswordInterceptor : HandlerInterceptor { class MustChangePasswordInterceptor : HandlerInterceptor {
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
handler: Any, handler: Any,
): Boolean { ): Boolean {
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser val user =
?: return true request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
?: return true
if (!user.mustChangePassword) return true if (!user.mustChangePassword) return true
response.sendRedirect("/change-password") response.sendRedirect("/change-password")
return false return false

View File

@@ -17,8 +17,6 @@ import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient 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.GradeDto
import ru.ysu.lms.web.api.MeAssignmentDto
import ru.ysu.lms.web.api.SubmissionDto import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import java.math.BigDecimal import java.math.BigDecimal
@@ -32,12 +30,9 @@ class StudentController(
private val api: ApiClient, private val api: ApiClient,
private val markdown: MarkdownRenderer, private val markdown: MarkdownRenderer,
) { ) {
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
private fun token(session: HttpSession): String? = private fun studentGroupId(t: String): String? = api.myGroup(t).firstOrNull()?.id
session.getAttribute(SessionKeys.TOKEN) as? String
private fun studentGroupId(t: String): String? =
api.myGroup(t).firstOrNull()?.id
data class LabCard( data class LabCard(
val id: String, val id: String,
@@ -52,7 +47,10 @@ class StudentController(
) )
@GetMapping @GetMapping
fun dashboard(session: HttpSession, model: Model): String { fun dashboard(
session: HttpSession,
model: Model,
): String {
val t = token(session) ?: return "redirect:/login" val t = token(session) ?: return "redirect:/login"
val cards = buildCards(t) val cards = buildCards(t)
model.addAttribute("cards", cards) model.addAttribute("cards", cards)
@@ -65,7 +63,10 @@ class StudentController(
} }
@GetMapping("/labs") @GetMapping("/labs")
fun labs(session: HttpSession, model: Model): String { fun labs(
session: HttpSession,
model: Model,
): String {
val t = token(session) ?: return "redirect:/login" val t = token(session) ?: return "redirect:/login"
model.addAttribute("cards", buildCards(t)) model.addAttribute("cards", buildCards(t))
return "student/labs" return "student/labs"
@@ -94,9 +95,18 @@ class StudentController(
val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null
model.addAttribute("canSeeGrade", myGrade != null) model.addAttribute("canSeeGrade", myGrade != null)
model.addAttribute("grade", myGrade?.score) model.addAttribute("grade", myGrade?.score)
model.addAttribute("commentsPositive", myGrade?.comments?.filter { it.tone == "positive" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>()) model.addAttribute(
model.addAttribute("commentsNeutral", myGrade?.comments?.filter { it.tone == "neutral" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>()) "commentsPositive",
model.addAttribute("commentsNegative", myGrade?.comments?.filter { it.tone == "negative" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>()) myGrade?.comments?.filter { it.tone == "positive" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>(),
)
model.addAttribute(
"commentsNeutral",
myGrade?.comments?.filter { it.tone == "neutral" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>(),
)
model.addAttribute(
"commentsNegative",
myGrade?.comments?.filter { it.tone == "negative" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>(),
)
val defense = api.myDefense(t, id) val defense = api.myDefense(t, id)
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" } defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
model.addAttribute("defense", defense) model.addAttribute("defense", defense)
@@ -108,8 +118,6 @@ class StudentController(
} }
} }
@PostMapping("/labs/{id}/submit") @PostMapping("/labs/{id}/submit")
fun submit( fun submit(
@PathVariable id: String, @PathVariable id: String,
@@ -119,8 +127,10 @@ class StudentController(
): String { ): String {
val t = token(session) ?: return "redirect:/login" val t = token(session) ?: return "redirect:/login"
return try { return try {
val payload = files.filter { !it.isEmpty } val payload =
.map { (it.originalFilename ?: "file") to it.bytes } files
.filter { !it.isEmpty }
.map { (it.originalFilename ?: "file") to it.bytes }
if (payload.isEmpty()) throw ApiException(400, "Выберите файлы или архив") if (payload.isEmpty()) throw ApiException(400, "Выберите файлы или архив")
api.submitLab(t, id, payload) api.submitLab(t, id, payload)
redirect.addFlashAttribute("notice", "Решение отправлено. Можно пересдать до истечения срока.") redirect.addFlashAttribute("notice", "Решение отправлено. Можно пересдать до истечения срока.")
@@ -168,11 +178,15 @@ class StudentController(
} }
@GetMapping("/submissions/{id}/download") @GetMapping("/submissions/{id}/download")
fun download(@PathVariable id: String, session: HttpSession): ResponseEntity<ByteArray> { fun download(
@PathVariable id: String,
session: HttpSession,
): ResponseEntity<ByteArray> {
val t = token(session) ?: return ResponseEntity.status(401).build() val t = token(session) ?: return ResponseEntity.status(401).build()
val file = api.downloadMySubmission(t, id) val file = api.downloadMySubmission(t, id)
val encoded = URLEncoder.encode(file.filename, StandardCharsets.UTF_8) val encoded = URLEncoder.encode(file.filename, StandardCharsets.UTF_8)
return ResponseEntity.ok() return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file.bytes) .body(file.bytes)
@@ -200,10 +214,17 @@ class StudentController(
} }
} }
private fun SubmissionDto.copyMsk(): SubmissionDto = SubmissionDto( private fun SubmissionDto.copyMsk(): SubmissionDto =
id = id, seq = seq, studentId = studentId, labId = labId, SubmissionDto(
createdAt = ru.ysu.lms.web.DateUtils.formatMsk(createdAt) ?: createdAt, files = files, id = id,
) seq = seq,
studentId = studentId,
labId = labId,
createdAt =
ru.ysu.lms.web.DateUtils
.formatMsk(createdAt) ?: createdAt,
files = files,
)
private fun canSubmit(deadlineAt: String?): Boolean { private fun canSubmit(deadlineAt: String?): Boolean {
val d = deadlineAt ?: return true val d = deadlineAt ?: return true

View File

@@ -0,0 +1,30 @@
package ru.ysu.lms.web
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class DateUtilsTest {
@Test
fun `formats instant in Moscow time`() {
assertThat(DateUtils.formatMsk("2026-11-02T20:59:00Z")).isEqualTo("02.11.2026 23:59")
}
@Test
fun `formats instant for datetime-local input`() {
assertThat(DateUtils.localInput("2026-11-02T20:59:00Z")).isEqualTo("2026-11-02T23:59")
}
@Test
fun `converts local input back to instant`() {
assertThat(DateUtils.toUtcIso("2026-11-02T23:59")).isEqualTo("2026-11-02T20:59:00Z")
}
@Test
fun `handles blank and invalid values`() {
assertThat(DateUtils.formatMsk(null)).isNull()
assertThat(DateUtils.formatMsk("не-дата")).isNull()
assertThat(DateUtils.localInput(null)).isEmpty()
assertThat(DateUtils.toUtcIso(" ")).isNull()
assertThat(DateUtils.toUtcIso("мусор")).isNull()
}
}