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

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()
@@ -38,4 +37,4 @@ object DateUtils {
} catch (_: Exception) {
null
}
}
}

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 {
@@ -292,4 +310,4 @@ class ContentAdminController(
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}
}

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()
}
}