Build: enable ktlint+detekt, format sources, add core unit tests
This commit is contained in:
@@ -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
@@ -35,6 +35,7 @@ data class AdminStudentGrades(
|
||||
val comments: List<List<Pair<String, String>>>,
|
||||
val commentsJson: String,
|
||||
)
|
||||
|
||||
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
|
||||
data class GroupLabCell(
|
||||
val labId: String = "",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import java.util.UUID
|
||||
*/
|
||||
@Component
|
||||
class CsrfInterceptor : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.springframework.web.servlet.HandlerInterceptor
|
||||
|
||||
@Component
|
||||
class AdminInterceptor : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.springframework.web.servlet.HandlerInterceptor
|
||||
|
||||
@Component
|
||||
class AuthInterceptor : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user