feat(web): кабинет преподавателя и управление преподавателями

This commit is contained in:
2026-10-27 15:47:01 +03:00
parent 8cc658bf97
commit dd1129fe80
17 changed files with 602 additions and 12 deletions

View File

@@ -27,6 +27,7 @@ import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listCourses import ru.ysu.lms.web.api.listCourses
import ru.ysu.lms.web.api.listGroups import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.listTeachers
import ru.ysu.lms.web.api.removeCourseTeacher import ru.ysu.lms.web.api.removeCourseTeacher
import ru.ysu.lms.web.api.updateCourse import ru.ysu.lms.web.api.updateCourse
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
@@ -115,11 +116,17 @@ class AdminCourseController(
val groups = api.listGroups(t) val groups = api.listGroups(t)
val attachedGroups = course.groups.filter { it.removedAt == null } val attachedGroups = course.groups.filter { it.removedAt == null }
val attachedIds = attachedGroups.map { it.groupId }.toSet() val attachedIds = attachedGroups.map { it.groupId }.toSet()
val teachers = api.listTeachers(t)
val activeTeachers = course.teachers.filter { it.removedAt == null }
val activeTeacherIds = activeTeachers.map { it.accountId }.toSet()
model.addAttribute("course", course) model.addAttribute("course", course)
model.addAttribute("attachedGroups", attachedGroups) model.addAttribute("attachedGroups", attachedGroups)
model.addAttribute("availableGroups", groups.filter { it.id !in attachedIds && it.status == "ACTIVE" }) model.addAttribute("availableGroups", groups.filter { it.id !in attachedIds && it.status == "ACTIVE" })
model.addAttribute("groupNames", groups.associate { it.id to it.name }) model.addAttribute("groupNames", groups.associate { it.id to it.name })
model.addAttribute("activeTeachers", course.teachers.filter { it.removedAt == null }) model.addAttribute("teachers", teachers)
model.addAttribute("availableTeachers", teachers.filter { it.id !in activeTeacherIds })
model.addAttribute("teacherNames", teachers.associate { it.id to teacherLabel(it.name, it.login) })
model.addAttribute("activeTeachers", activeTeachers)
model.addAttribute("labs", api.listLabs(t, id)) model.addAttribute("labs", api.listLabs(t, id))
return "admin/course" return "admin/course"
} }
@@ -211,6 +218,11 @@ class AdminCourseController(
private fun requireAccountId(form: CourseTeacherForm): String = private fun requireAccountId(form: CourseTeacherForm): String =
form.accountId.trim().ifBlank { throw ApiException(400, "Укажите идентификатор преподавателя") } form.accountId.trim().ifBlank { throw ApiException(400, "Укажите идентификатор преподавателя") }
private fun teacherLabel(
name: String,
login: String,
): String = if (name.isBlank()) login else "$name ($login)"
private fun normalizeStatus(value: String): String { private fun normalizeStatus(value: String): String {
val status = value.trim().uppercase() val status = value.trim().uppercase()
if (status !in COURSE_STATUSES) throw ApiException(400, "Недопустимый статус курса") if (status !in COURSE_STATUSES) throw ApiException(400, "Недопустимый статус курса")

View File

@@ -64,6 +64,11 @@ data class CourseTeacherForm(
val accountId: String = "", val accountId: String = "",
) )
data class TeacherCreateForm(
val login: String? = null,
val password: String? = null,
)
data class CourseGroupForm( data class CourseGroupForm(
val groupId: String = "", val groupId: String = "",
) )

View File

@@ -0,0 +1,79 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.TeacherCreatePayload
import ru.ysu.lms.web.api.createTeacher
import ru.ysu.lms.web.api.listTeachers
import ru.ysu.lms.web.api.resetTeacherPassword
import tools.jackson.databind.ObjectMapper
@Controller
@RequestMapping("/admin/teachers")
class AdminTeacherController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@GetMapping
fun list(
session: HttpSession,
model: Model,
): String {
model.addAttribute("teachers", api.listTeachers(token(session)))
return "admin/teachers"
}
@PostMapping
fun create(
@ModelAttribute form: TeacherCreateForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val created =
api.createTeacher(
token(session),
TeacherCreatePayload(
login = form.login.orEmpty().trim(),
password = form.password?.takeIf { it.isNotBlank() },
),
)
redirect.addFlashAttribute("notice", "Преподаватель ${created.login} создан")
redirect.addFlashAttribute("teacherPassword", created.password)
redirect.addFlashAttribute("teacherLogin", created.login)
"redirect:/admin/teachers"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/teachers"
}
@PostMapping("/{id}/reset-password")
fun resetPassword(
@PathVariable id: String,
@RequestParam(required = false) login: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val result = api.resetTeacherPassword(token(session), id)
redirect.addFlashAttribute("notice", "Пароль преподавателя сброшен")
redirect.addFlashAttribute("teacherPassword", result.password)
login?.takeIf { it.isNotBlank() }?.let { redirect.addFlashAttribute("teacherLogin", it) }
"redirect:/admin/teachers"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/teachers"
}
}

View File

@@ -16,6 +16,19 @@ fun ApiClient.listCourses(token: String): List<CourseDto> {
} }
} }
/** Курсы текущего пользователя: для преподавателя — назначенные ему. */
fun ApiClient.myCourses(token: String): List<CourseDto> {
val type = object : ParameterizedTypeReference<List<CourseDto>>() {}
return call {
client
.get()
.uri("/api/v1/courses/mine")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.createCourse( fun ApiClient.createCourse(
token: String, token: String,
payload: CourseCreatePayload, payload: CourseCreatePayload,

View File

@@ -0,0 +1,45 @@
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
fun ApiClient.listTeachers(token: String): List<TeacherDto> {
val type = object : ParameterizedTypeReference<List<TeacherDto>>() {}
return call {
client
.get()
.uri("/api/v1/teachers")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.createTeacher(
token: String,
payload: TeacherCreatePayload,
): TeacherCredentialDto =
call {
client
.post()
.uri("/api/v1/teachers")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(TeacherCredentialDto::class.java)
}
fun ApiClient.resetTeacherPassword(
token: String,
teacherId: String,
): ResetPasswordDto =
call {
client
.post()
.uri("/api/v1/teachers/{id}/reset-password", teacherId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(ResetPasswordDto::class.java)
}

View File

@@ -130,6 +130,27 @@ data class AdminUserDto(
val groupName: String? = null, val groupName: String? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class TeacherDto(
val id: String = "",
val login: String = "",
val name: String = "",
val status: String = "ACTIVE",
val mustChangePassword: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class TeacherCredentialDto(
val id: String = "",
val login: String = "",
val password: String = "",
)
data class TeacherCreatePayload(
val login: String,
val password: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DeleteUserDto( data class DeleteUserDto(
val id: String = "", val id: String = "",

View File

@@ -100,7 +100,7 @@ class AuthPageController(
return try { return try {
api.changePassword(token, old, new, new2) api.changePassword(token, old, new, new2)
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false)) session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
"redirect:" + if (user.isAdmin) "/admin" else "/student" "redirect:" + homeFor(user)
} catch (ex: ApiException) { } catch (ex: ApiException) {
model.addAttribute("error", ex.message) model.addAttribute("error", ex.message)
"change_password" "change_password"
@@ -139,6 +139,14 @@ class AuthPageController(
when { when {
auth.mustChangePassword -> "redirect:/change-password" auth.mustChangePassword -> "redirect:/change-password"
auth.role == "ADMIN" -> "redirect:/admin" auth.role == "ADMIN" -> "redirect:/admin"
auth.role == "TEACHER" -> "redirect:/teacher"
else -> "redirect:/student" else -> "redirect:/student"
} }
private fun homeFor(user: SessionUser): String =
when {
user.isAdmin -> "/admin"
user.isTeacher -> "/teacher"
else -> "/student"
}
} }

View File

@@ -13,6 +13,7 @@ import ru.ysu.lms.web.session.AuthInterceptor
import ru.ysu.lms.web.session.MustChangePasswordInterceptor import ru.ysu.lms.web.session.MustChangePasswordInterceptor
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser import ru.ysu.lms.web.session.SessionUser
import ru.ysu.lms.web.session.TeacherInterceptor
/** Динамические страницы не кэшируются: браузер всегда берёт свежую разметку со свежими ссылками на css/js. */ /** Динамические страницы не кэшируются: браузер всегда берёт свежую разметку со свежими ссылками на css/js. */
class NoCachePageInterceptor : HandlerInterceptor { class NoCachePageInterceptor : HandlerInterceptor {
@@ -30,6 +31,7 @@ class NoCachePageInterceptor : HandlerInterceptor {
class WebConfig( class WebConfig(
private val authInterceptor: AuthInterceptor, private val authInterceptor: AuthInterceptor,
private val adminInterceptor: AdminInterceptor, private val adminInterceptor: AdminInterceptor,
private val teacherInterceptor: TeacherInterceptor,
private val csrfInterceptor: CsrfInterceptor, private val csrfInterceptor: CsrfInterceptor,
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor, private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
private val formFeedbackInterceptor: FormFeedbackInterceptor, private val formFeedbackInterceptor: FormFeedbackInterceptor,
@@ -55,10 +57,13 @@ class WebConfig(
) )
registry registry
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/change-password") .addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password")
registry registry
.addInterceptor(adminInterceptor) .addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**") .addPathPatterns("/admin/**")
registry
.addInterceptor(teacherInterceptor)
.addPathPatterns("/teacher/**")
} }
} }

View File

@@ -8,6 +8,7 @@ data class SessionUser(
val mustChangePassword: Boolean, val mustChangePassword: Boolean,
) { ) {
val isAdmin: Boolean get() = role == "ADMIN" val isAdmin: Boolean get() = role == "ADMIN"
val isTeacher: Boolean get() = role == "TEACHER"
val isStudent: Boolean get() = role == "STUDENT" val isStudent: Boolean get() = role == "STUDENT"
} }

View File

@@ -0,0 +1,27 @@
package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
/** Кабинет преподавателя доступен преподавателям и администраторам. */
@Component
class TeacherInterceptor : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
if (user == null) {
response.sendRedirect("/login")
return false
}
if (!user.isTeacher && !user.isAdmin) {
response.sendRedirect("/student")
return false
}
return true
}
}

View File

@@ -0,0 +1,70 @@
package ru.ysu.lms.web.teacher
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.RequestMapping
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.myCourses
import ru.ysu.lms.web.session.SessionKeys
@Controller
@RequestMapping("/teacher")
class TeacherController(
private val api: ApiClient,
) {
/** Строка лабораторной для кабинета преподавателя: только чтение. */
data class LabRow(
val id: String,
val number: Int,
val title: String,
val status: String,
val variantCount: Int,
val deadlineMsk: String?,
val defenseMsk: String?,
)
@GetMapping
fun dashboard(
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
model.addAttribute("courses", api.myCourses(token))
return "teacher/dashboard"
}
@GetMapping("/courses/{id}")
fun course(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val course = api.courseDetail(token, id)
model.addAttribute("course", course)
model.addAttribute("groups", course.groups.filter { it.removedAt == null })
model.addAttribute(
"labs",
api.listLabs(token, id).map { lab ->
LabRow(
id = lab.id,
number = lab.number,
title = lab.title,
status = lab.status,
variantCount = lab.variantCount,
deadlineMsk = DateUtils.formatMsk(lab.deadlineAt),
defenseMsk = DateUtils.formatMsk(lab.defenseAt),
)
},
)
return "teacher/course"
}
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
}

View File

@@ -75,10 +75,14 @@
<h2 class="section-title">Преподаватели</h2> <h2 class="section-title">Преподаватели</h2>
<div class="card card-flush"> <div class="card card-flush">
<table> <table>
<thead><tr><th>Аккаунт</th><th>Назначен</th><th data-nosort></th></tr></thead> <thead><tr><th>Преподаватель</th><th>Назначен</th><th data-nosort></th></tr></thead>
<tbody> <tbody>
<tr th:each="teacher : ${activeTeachers}"> <tr th:each="teacher : ${activeTeachers}">
<td class="font-mono" th:text="${teacher.accountId}">—</td> <td>
<span class="font-medium"
th:text="${teacherNames[teacher.accountId] ?: teacher.accountId}">—</span>
<div class="text-sm text-muted font-mono" th:text="${teacher.accountId}">—</div>
</td>
<td class="text-muted" th:text="${teacher.assignedAt}">—</td> <td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center"> <td class="text-center">
<form class="inline-form" <form class="inline-form"
@@ -98,11 +102,27 @@
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/teachers(id=${course.id})}" <form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/teachers(id=${course.id})}"
method="post"> method="post">
<div><label class="label" for="teacherAccount">Идентификатор аккаунта преподавателя</label> <div><label class="label" for="teacherAccount">Преподаватель</label>
<input class="input font-mono" id="teacherAccount" name="accountId" required <select class="input" id="teacherAccount" name="accountId" required
placeholder="UUID аккаунта"></div> th:disabled="${#lists.isEmpty(availableTeachers)}">
<p class="hint">UUID можно скопировать на странице «Пользователи».</p> <option value="">— выберите преподавателя —</option>
<button class="btn btn-primary" type="submit">Назначить</button> <option th:each="t : ${availableTeachers}" th:value="${t.id}"
th:text="${#strings.isEmpty(t.name) ? t.login : t.name + ' (' + t.login + ')'}">—</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(teachers)}">
Преподавателей ещё нет.
<a th:href="@{/admin/teachers}">Создайте первого на странице «Преподаватели»</a>.
</p>
<p class="hint" th:if="${!#lists.isEmpty(teachers) and #lists.isEmpty(availableTeachers)}">
Все преподаватели уже назначены на курс.
</p>
<p class="hint" th:if="${!#lists.isEmpty(availableTeachers)}">
Нет нужного?
<a th:href="@{/admin/teachers}">Создать преподавателя на странице «Преподаватели»</a>.
</p>
</div>
<button class="btn btn-primary" type="submit"
th:disabled="${#lists.isEmpty(availableTeachers)}">Назначить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Преподаватели — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Преподаватели</h1>
<form class="card card-body form-stack mb-6" th:action="@{/admin/teachers}" method="post">
<h2 class="section-title m-0">Новый преподаватель</h2>
<div class="row g-3">
<div class="col-md-6">
<label class="label" for="teacherLogin">Логин</label>
<input class="input" id="teacherLogin" name="login" autocomplete="off"
placeholder="ivanov" required minlength="3">
</div>
<div class="col-md-6">
<label class="label" for="teacherPassword">Пароль (необязательно)</label>
<input class="input" id="teacherPassword" name="password" type="password"
autocomplete="new-password" placeholder="Оставьте пустым — сгенерируем">
</div>
</div>
<p class="hint">
Логин — минимум 3 символа. Если пароль не указан, сервис сгенерирует его.
Одноразовый пароль будет показан один раз после создания — передайте его преподавателю.
</p>
<button class="btn btn-primary" type="submit">Создать преподавателя</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<div class="card card-flush">
<table>
<thead>
<tr>
<th>Преподаватель</th>
<th>Логин</th>
<th>Статус</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="t : ${teachers}">
<td>
<span class="font-medium" th:text="${#strings.isEmpty(t.name) ? t.login : t.name}">—</span>
<div class="text-sm text-muted font-mono" th:text="${t.id}">—</div>
</td>
<td class="font-mono" th:text="${t.login}">—</td>
<td>
<span class="badge" th:classappend="${t.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${t.status == 'ACTIVE' ? 'активен' : t.status}">—</span>
<span class="badge badge-warn" th:if="${t.mustChangePassword}">временный пароль</span>
</td>
<td class="text-right">
<form class="inline-form"
th:action="@{/admin/teachers/{id}/reset-password(id=${t.id})}"
method="post" data-confirm="Сбросить пароль преподавателя? Старый пароль перестанет действовать.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="login" th:value="${t.login}">
<button class="btn btn-sm btn-secondary" type="submit">Сбросить пароль</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(teachers)}">
<td colspan="4" class="empty">Преподавателей пока нет. Создайте первого в форме выше.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -51,13 +51,16 @@
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/groups}">Группы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/groups}">Группы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and !currentUser.admin}"><a class="nav-link" th:href="@{/student/labs}">Лабораторные</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.student}"><a class="nav-link" th:href="@{/student/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null}"><a class="nav-link" th:href="@{/change-password}">Смена пароля</a></li> <li class="nav-item" th:if="${currentUser != null}"><a class="nav-link" th:href="@{/change-password}">Смена пароля</a></li>
</ul> </ul>
<div class="navbar-user d-flex flex-column flex-md-row align-items-start align-items-md-center gap-2"> <div class="navbar-user d-flex flex-column flex-md-row align-items-start align-items-md-center gap-2">
<a th:href="@{/admin}" th:if="${currentUser != null and currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a> <a th:href="@{/admin}" th:if="${currentUser != null and currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:if="${currentUser != null and !currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a> <a th:href="@{/teacher}" th:if="${currentUser != null and currentUser.teacher}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:if="${currentUser != null and currentUser.student}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-secondary btn-sm theme-btn" aria-label="Сменить тему" title="Сменить тему"> <button type="button" class="btn btn-secondary btn-sm theme-btn" aria-label="Сменить тему" title="Сменить тему">
<i class="bi bi-circle-half"></i> <i class="bi bi-circle-half"></i>
@@ -97,6 +100,13 @@
</span> </span>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button> <button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div th:if="${teacherPassword}" class="toast-item toast-warning" data-toast>
<span class="toast-text">
Одноразовый пароль<span th:if="${teacherLogin}" th:text="| для ${teacherLogin}|"> для login</span>:
<code class="code font-mono" th:text="${teacherPassword}">password</code>
</span>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
</div> </div>
<dialog id="modal-confirm" class="dlg"> <dialog id="modal-confirm" class="dlg">
<div class="dlg-header"> <div class="dlg-header">

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${course.title} + ' — LMS'">Курс</title></head>
<body>
<div id="content">
<a th:href="@{/teacher}" class="nav-link">← Мои курсы</a>
<div class="page-head mb-2">
<div>
<h1 class="page-title" th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</h1>
<div class="meta">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
</div>
</div>
</div>
<div class="row g-4 mt-4">
<div class="col-lg-6">
<h2 class="section-title">Группы курса</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Группа</th><th>Добавлена</th></tr></thead>
<tbody>
<tr th:each="group : ${groups}">
<td>
<a th:if="${currentUser.admin}"
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
class="font-mono"
th:text="${group.groupId}">—</a>
<span th:unless="${currentUser.admin}" class="font-mono"
th:text="${group.groupId}">—</span>
</td>
<td class="text-muted" th:text="${group.addedAt}">—</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="2" class="empty">К курсу не привязано ни одной группы.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint mt-2">Состав групп и привязку к курсу изменяет администратор.</p>
</div>
<div class="col-lg-6">
<h2 class="section-title">Сведения о курсе</h2>
<div class="card card-body">
<p class="text-sm">Создан: <span th:text="${course.createdAt}">—</span></p>
<p class="text-sm">Обновлён: <span th:text="${course.updatedAt}">—</span></p>
<p class="text-sm" th:if="${!#lists.isEmpty(course.teachers)}">
Преподавателей на курсе: <span th:text="${#lists.size(course.teachers)}">0</span>
</p>
</div>
</div>
</div>
<h2 class="section-title mt-8 mb-2">Лабораторные курса</h2>
<div class="card card-flush">
<table>
<thead>
<tr><th>№</th><th>Название</th><th>Статус</th><th>Вариантов</th><th>Срок сдачи</th><th>Защита</th></tr>
</thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td>
<a th:if="${currentUser.admin}"
th:href="@{/admin/labs/{id}(id=${lab.id})}"
th:text="${lab.title}">—</a>
<span th:unless="${currentUser.admin}" th:text="${lab.title}">—</span>
</td>
<td>
<span class="badge"
th:classappend="${lab.status == 'published' ? 'badge-ok' : 'badge-warn'}"
th:text="${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">—</span>
</td>
<td th:text="${lab.variantCount}">0</td>
<td class="text-muted" th:text="${lab.deadlineMsk ?: '—'}">—</td>
<td class="text-muted" th:text="${lab.defenseMsk ?: '—'}">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}">
<td colspan="6" class="empty">В курсе пока нет лабораторных.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Мои курсы — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">преподаватель</span></h1>
<p class="meta">Курсы, на которые вы назначены.</p>
<div class="row row-cols-1 row-cols-lg-2 g-4 mt-4">
<div th:each="course : ${courses}" class="col">
<div class="card h-100">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}"
th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<span class="badge" th:text="|групп: ${course.groupCount}|">групп: 0</span>
<span class="badge" th:text="|лабораторных: ${course.labCount}|">лабораторных: 0</span>
</div>
<div class="mt-4">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{id}(id=${course.id})}">Открыть курс</a>
</div>
</div>
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="col">
<div class="card card-body text-muted">
Вам пока не назначено ни одного курса. Обратитесь к администратору.
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-8">
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,69 @@
package ru.ysu.lms.web.session
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.stereotype.Controller
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.bind.annotation.GetMapping
class TeacherInterceptorTest {
@Controller
private class TeacherPage {
@GetMapping("/teacher/test")
fun page(): String = "teacher/dashboard"
}
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(TeacherPage())
.addInterceptors(TeacherInterceptor())
.build()
@Test
fun `teacher may open teacher pages`() {
mockMvc
.perform(get("/teacher/test").session(sessionFor("TEACHER")))
.andExpect(status().isOk)
}
@Test
fun `admin may open teacher pages`() {
mockMvc
.perform(get("/teacher/test").session(sessionFor("ADMIN")))
.andExpect(status().isOk)
}
@Test
fun `student is redirected to student cabinet`() {
mockMvc
.perform(get("/teacher/test").session(sessionFor("STUDENT")))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/student"))
}
@Test
fun `anonymous user is redirected to login`() {
mockMvc
.perform(get("/teacher/test"))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login"))
}
private fun sessionFor(role: String): MockHttpSession =
MockHttpSession().apply {
setAttribute(
SessionKeys.USER,
SessionUser(
id = "test-id",
login = "test-login",
role = role,
name = "Тестовый пользователь",
mustChangePassword = false,
),
)
}
}