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.kotlinNoargPlugin)
implementation(libs.springBootGradlePlugin)
implementation(libs.ktlintGradlePlugin)
implementation(libs.detektGradlePlugin)
}

View File

@@ -4,6 +4,7 @@ dependencyResolutionManagement {
@Suppress("UnstableApiUsage")
repositories {
mavenCentral()
gradlePluginPortal()
}
// 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.jpa")
id("org.springframework.boot")
id("lms.lint")
}
group = "ru.ysu.lms"

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,16 +8,25 @@ import java.nio.file.Path
import java.util.UUID
@Component
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
class AssetStorage(
@Value("\${lms.content.assets-dir:/data/content-assets}") root: String,
) {
private val rootDir: Path = Path.of(root)
init {
Files.createDirectories(rootDir)
}
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
data class StoredAsset(
val publicName: String,
val storedPath: Path,
val mime: String,
)
fun store(originalName: String, bytes: ByteArray): StoredAsset {
fun store(
originalName: String,
bytes: ByteArray,
): StoredAsset {
val ext = originalName.substringAfterLast('.', "").lowercase()
val isVideo = ext in VIDEO_EXTENSIONS
val isImage = ext in IMAGE_EXTENSIONS
@@ -50,22 +59,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return target to mimeFor(ext)
}
private fun mimeFor(ext: String): String = when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
private fun mimeFor(ext: String): String =
when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
private companion object {
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
}
}

View File

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

View File

@@ -23,9 +23,14 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController(private val contentService: ContentService) {
class ContentAdminController(
private val contentService: ContentService,
) {
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
val title: String,
val bodyMd: String = "",
)
data class ArticleMovePayload(val sectionId: String)
data class TitlePayload(val title: String)
data class PublishedPayload(val published: Boolean)
data class ChapterCreatePayload(val title: String, val slug: String? = null)
data class SectionCreatePayload(val title: String, val slug: String? = null)
data class ArticleMovePayload(
val sectionId: String,
)
data class TitlePayload(
val title: String,
)
data class PublishedPayload(
val published: Boolean,
)
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
/** Полное дерево материалов, включая скрытые (для админки). */
@GetMapping("/tree/all")
@@ -53,42 +75,65 @@ class ContentAdminController(private val contentService: ContentService) {
): ContentPublicController.ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
return ContentPublicController.ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@PutMapping("/chapters/{id}/published")
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun chapterPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setChapterPublished(id, body.published)
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun sectionPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setSectionPublished(id, body.published)
}
@PutMapping("/articles/{id}/published")
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun articlePublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setArticlePublished(id, body.published)
}
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
@PostMapping("/reorder")
fun reorder(@RequestBody body: ReorderTree) {
fun reorder(
@RequestBody body: ReorderTree,
) {
contentService.reorder(body)
}
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@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)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@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)
}
@@ -105,25 +150,35 @@ class ContentAdminController(private val contentService: ContentService) {
fun list() = contentService.listAll()
@PostMapping("/chapters")
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
fun createChapter(
@RequestBody body: ChapterCreatePayload,
) = contentService.createChapter(body.title, body.slug)
@PutMapping("/chapters/{id}")
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameChapter(id, body.title)
fun renameChapter(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = contentService.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
fun deleteChapter(
@PathVariable id: UUID,
): ResponseEntity<Void> {
contentService.deleteChapter(id)
return ResponseEntity.noContent().build()
}
@PostMapping("/sections")
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
contentService.createSection(chapterId, body.title, body.slug)
fun createSection(
@RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload,
) = contentService.createSection(chapterId, body.title, body.slug)
@PutMapping("/sections/{id}")
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameSection(id, body.title)
fun renameSection(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = contentService.renameSection(id, body.title)
@DeleteMapping("/sections/{id}")
fun deleteSection(
@@ -139,7 +194,9 @@ class ContentAdminController(private val contentService: ContentService) {
}
@PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
fun createArticle(
@RequestBody body: ArticleCreatePayload,
) = contentService.createArticlePlaced(
title = body.title,
bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) },
@@ -149,16 +206,23 @@ class ContentAdminController(private val contentService: ContentService) {
)
@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)
}
@PostMapping("/articles/{id}/move")
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
contentService.moveArticle(id, UUID.fromString(body.sectionId))
fun move(
@PathVariable id: UUID,
@RequestBody body: ArticleMovePayload,
) = contentService.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
fun delete(
@PathVariable id: UUID,
): ResponseEntity<Void> {
contentService.deleteArticle(id)
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 ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentService
import java.nio.file.Files
/** Публичное чтение учебных материалов — доступно без аутентификации. */
@RestController
@@ -18,7 +17,6 @@ class ContentPublicController(
private val contentService: ContentService,
private val assetStorage: AssetStorage,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
@@ -30,9 +28,15 @@ class ContentPublicController(
): ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article)
return ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@@ -49,13 +53,19 @@ class ContentPublicController(
)
@GetMapping("/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
val (path, mime) = assetStorage.read(name)
?: return ResponseEntity.notFound().build()
fun asset(
@PathVariable name: String,
): ResponseEntity<FileSystemResource> {
val (path, mime) =
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok()
return ResponseEntity
.ok()
.contentType(media)
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
.body(FileSystemResource(path))
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
}
}

View File

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

View File

@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,14 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import org.springframework.web.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 java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -21,9 +28,10 @@ class ProgressController(
private val submissions: SubmissionService,
private val academic: AcademicClient,
) {
@GetMapping("/me/assignments")
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
fun meAssignments(
@AuthenticationPrincipal jwt: Jwt,
): List<MeAssignmentResponse> {
val sid = UUID.fromString(jwt.subject)
return service.getStudentAssignments(sid).mapNotNull { va ->
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
@@ -31,9 +39,9 @@ class ProgressController(
}
@GetMapping("/me/labs")
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
return listOf()
}
fun meLabs(
@AuthenticationPrincipal jwt: Jwt,
): List<Map<String, Any>> = listOf()
@PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -44,7 +52,9 @@ class ProgressController(
@GetMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
fun getLevels(
@RequestParam labId: UUID,
): List<LevelAssignmentResponse> =
service.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse(
studentId = sll.studentId.toString(),
@@ -77,7 +87,9 @@ class ProgressController(
@PostMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
fun setGrade(
@Valid @RequestBody body: GradeSetRequest,
): GradeResponse = service.setGrade(body)
@GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -85,19 +97,26 @@ class ProgressController(
@PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
fun publishGrades(
@RequestParam labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
@RequestParam published: Boolean,
) {
publications.setPublished(groupId, labId, published)
}
@GetMapping("/grades/published")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
publications.isPublished(groupId, labId)
fun gradesPublished(
@RequestParam("labId") labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
): Boolean = publications.isPublished(groupId, labId)
@GetMapping("/grades/publications")
@PreAuthorize("isAuthenticated()")
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
publications.publishedLabIdsForGroup(groupId)
fun groupPublications(
@RequestParam groupId: java.util.UUID,
): List<String> = publications.publishedLabIdsForGroup(groupId)
@GetMapping("/me/grades")
fun myGrades(
@@ -111,7 +130,9 @@ class ProgressController(
@GetMapping("/comment-templates")
@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}")
fun submit(
@@ -131,7 +152,10 @@ class ProgressController(
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@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 (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name)
@@ -139,22 +163,29 @@ class ProgressController(
@GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
submissions.latestPerStudent(labId)
fun labSubmissions(
@RequestParam labId: UUID,
): List<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name)
}
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
ResponseEntity.ok()
private fun attachment(
bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.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.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import java.math.BigDecimal
import java.util.UUID
@@ -28,9 +28,11 @@ class ProgressService(
private val academic: AcademicClient,
private val identity: IdentityClient,
) {
@Transactional
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
fun setLevel(
request: LevelAssignmentRequest,
token: String,
): LevelAssignmentResponse {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = StudentLabLevelKey(sid, lid)
@@ -46,20 +48,27 @@ class ProgressService(
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
if (level != null && level.variants.isNotEmpty()) {
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
val currentInLevel = assignment?.variantId?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
val currentInLevel =
assignment
?.variantId
?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
if (!currentInLevel) {
val used = assignments.findByLabId(lid)
.map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val used =
assignments
.findByLabId(lid)
.map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val pool = level.variants.map { it.id }
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
?: pool.shuffled().first()
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(chosen)
manual = false
}
val chosen =
pool.filter { it !in used }.shuffled().firstOrNull()
?: pool.shuffled().first()
val target =
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(chosen)
manual = false
}
assignments.save(target)
}
}
@@ -72,23 +81,34 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
levels.findByStudentIdAndLabId(studentId, labId)
fun getLevel(
studentId: UUID,
labId: UUID,
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
@Transactional(readOnly = true)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
levels.findByLabId(labId)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
@Transactional
fun distribute(request: DistributeRequest, token: String): DistributeResult {
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
fun distribute(
request: DistributeRequest,
token: String,
): DistributeResult {
val labIds =
request.labIds
.orEmpty()
.ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
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>()
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
@@ -111,14 +131,16 @@ class ProgressService(
// force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть.
if (force) {
assignments.findByLabId(labId)
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments.findByLabId(labId)
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
@@ -132,17 +154,23 @@ class ProgressService(
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage = assignments.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
val usage =
assignments
.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
?: defaultLevel
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
val levelId =
levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
?: defaultLevel
val pool =
levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
@@ -174,23 +202,26 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
assignments.findByLabId(labId)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
@Transactional
fun assign(request: AssignRequest): VariantAssignment {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val existing = assignments.findByStudentIdAndLabId(sid, lid)
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(request.variantId)
manual = true
}
val entity =
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(request.variantId)
manual = true
}
return assignments.save(entity)
}
@Transactional
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
fun assignWithInfo(
request: AssignRequest,
token: String,
): VariantAssignmentResponse {
assign(request)
val lab = academic.getLab(request.labId, token)
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
@@ -204,7 +235,10 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
fun assignmentsForLabEnriched(
labId: UUID,
token: String,
): List<VariantAssignmentResponse> {
val lab = academic.getLab(labId.toString(), token)
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
return assignments.findByLabId(labId).map { va ->
@@ -220,8 +254,7 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
assignments.findByStudentId(studentId)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
@@ -235,11 +268,19 @@ class ProgressService(
grade.score = request.score
grades.save(grade)
replaceComments(sid, lid, request.comments)
return GradeResponse(request.studentId, request.labId, request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
return GradeResponse(
request.studentId,
request.labId,
request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
)
}
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
private fun replaceComments(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
@@ -247,23 +288,33 @@ class ProgressService(
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
else { t.useCount += 1; t.tone = tone; templates.save(t) }
if (t == null) {
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
} else {
t.useCount += 1
t.tone = tone
templates.save(t)
}
}
}
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
private fun normalizeTone(tone: String): String =
when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true)
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
fun commentsOf(
sid: UUID,
lid: UUID,
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
@@ -271,8 +322,10 @@ class ProgressService(
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade = gradeComments.findAll()
.groupBy { it.studentId to it.labId }
val byGrade =
gradeComments
.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
@@ -284,12 +337,19 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
fun getStudentAssignment(
studentId: UUID,
labId: UUID,
token: String,
): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
return enrich(va, token)
}
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
private fun enrich(
va: VariantAssignment,
token: String,
): MeAssignmentResponse? {
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }

View File

@@ -2,7 +2,6 @@ package ru.ysu.lms.progress
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
@SpringBootApplication
class ProgressServiceApplication

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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")
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
fun formatMsk(value: String?): String? =
toZoned(value)?.format(FORMAT)
fun formatMsk(value: String?): String? = toZoned(value)?.format(FORMAT)
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
fun localInput(value: String?): String? =
toZoned(value)?.format(INPUT_FORMAT) ?: ""
fun localInput(value: String?): String? = toZoned(value)?.format(INPUT_FORMAT) ?: ""
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
fun toUtcIso(localValue: String?): String? =
localValue?.takeIf { it.isNotBlank() }?.let { s ->
try {
LocalDateTime.parse(s, INPUT_FORMAT)
LocalDateTime
.parse(s, INPUT_FORMAT)
.atZone(MSK)
.toInstant()
.toString()

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 commentsJson: String,
)
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
data class GroupLabCell(
val labId: String = "",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,22 @@ data class AuthResponseDto(
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)
data class GroupDto(
@@ -32,7 +43,9 @@ data class GroupDto(
val taskDurationMin: Int = 30,
)
data class CreateGroupPayload(val name: String)
data class CreateGroupPayload(
val name: String,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CredentialDto(
@@ -77,7 +90,9 @@ data class StudentCandidateDto(
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "")
data class ResetPasswordDto(
val password: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabSummaryDto(
@@ -225,7 +240,10 @@ data class GradeDto(
)
@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)
data class SubmissionDto(
@@ -244,7 +262,10 @@ data class GradeSetPayload(
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(
val number: Int,
@@ -274,15 +295,29 @@ data class LevelRenamePayload(
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)
data class CreateStudentRequestDto(

View File

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

View File

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

View File

@@ -19,25 +19,31 @@ class WebConfig(
private val csrfInterceptor: CsrfInterceptor,
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry.addInterceptor(mustChangePasswordInterceptor)
registry
.addInterceptor(mustChangePasswordInterceptor)
.addPathPatterns("/**")
.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")
registry.addInterceptor(adminInterceptor)
registry
.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**")
}
}
@ControllerAdvice
class GlobalModelAdvice {
@ModelAttribute("currentUser")
fun currentUser(request: HttpServletRequest): SessionUser? =
request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
fun currentUser(request: HttpServletRequest): SessionUser? = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
}

View File

@@ -8,9 +8,12 @@ import ru.ysu.lms.web.api.ApiException
@ControllerAdvice
class WebErrorAdvice {
@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
model.addAttribute("status", ex.status)
model.addAttribute("message", ex.message)

View File

@@ -12,14 +12,19 @@ import ru.ysu.lms.web.api.ApiClient
* отдаются web, а src картинок указывает на этот же хост.
*/
@RestController
class ContentAssetProxy(private val api: ApiClient) {
class ContentAssetProxy(
private val api: ApiClient,
) {
@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 media = runCatching { MediaType.parseMediaType(asset.contentType) }
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok()
val media =
runCatching { MediaType.parseMediaType(asset.contentType) }
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity
.ok()
.contentType(media)
.body(asset.bytes)
}

View File

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

View File

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

View File

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

View File

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