feat(web): страницы курсов и привязка лабораторных

This commit is contained in:
2026-10-27 15:37:52 +03:00
parent 0c51bf2d12
commit 9a539407f1
11 changed files with 837 additions and 11 deletions

View File

@@ -0,0 +1,223 @@
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.CourseBootstrapPayload
import ru.ysu.lms.web.api.CourseCreatePayload
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.CourseTeacherPayload
import ru.ysu.lms.web.api.CourseUpdatePayload
import ru.ysu.lms.web.api.assignCourseTeacher
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.bootstrapCourses
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.createCourse
import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listCourses
import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.removeCourseTeacher
import ru.ysu.lms.web.api.updateCourse
import tools.jackson.databind.ObjectMapper
@Controller
@RequestMapping("/admin/courses")
class AdminCourseController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@GetMapping
fun list(
session: HttpSession,
model: Model,
): String {
val t = token(session)
val groups = api.listGroups(t)
model.addAttribute("courses", api.listCourses(t))
model.addAttribute("groups", groups)
model.addAttribute("activeGroupCount", groups.count { it.status == "ACTIVE" })
return "admin/courses"
}
@PostMapping
fun create(
@ModelAttribute form: CreateCourseForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val course = api.createCourse(token(session), CourseCreatePayload(form.title?.trim()?.takeIf { it.isNotBlank() }))
redirect.addFlashAttribute("notice", "Курс №${course.courseNo} создан")
"redirect:/admin/courses/${course.id}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses"
}
@PostMapping("/bootstrap")
fun bootstrap(
@ModelAttribute form: CreateCourseForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val t = token(session)
val groupIds = api.listGroups(t).filter { it.status == "ACTIVE" }.map { it.id }
val result =
api.bootstrapCourses(
t,
CourseBootstrapPayload(
groupIds = groupIds,
title = form.title?.trim()?.takeIf { it.isNotBlank() },
),
)
redirect.addFlashAttribute(
"notice",
"Курс «${result.title}» №${result.courseNo}: групп привязано ${result.attachedGroups}, " +
"лабораторных ${result.assignedLabs}, глав ${result.assignedChapters}" +
bootstrapTail(result.labsWithoutCourse, result.chaptersWithoutCourse),
)
"redirect:/admin/courses"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses"
}
private fun bootstrapTail(
labsWithoutCourse: Long,
chaptersWithoutCourse: Long,
): String =
buildString {
if (labsWithoutCourse > 0) append("; лабораторных без курса: $labsWithoutCourse")
if (chaptersWithoutCourse > 0) append("; глав без курса: $chaptersWithoutCourse")
}
@GetMapping("/{id}")
fun detail(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val course = api.courseDetail(t, id)
val groups = api.listGroups(t)
val attachedGroups = course.groups.filter { it.removedAt == null }
val attachedIds = attachedGroups.map { it.groupId }.toSet()
model.addAttribute("course", course)
model.addAttribute("attachedGroups", attachedGroups)
model.addAttribute("availableGroups", groups.filter { it.id !in attachedIds && it.status == "ACTIVE" })
model.addAttribute("groupNames", groups.associate { it.id to it.name })
model.addAttribute("activeTeachers", course.teachers.filter { it.removedAt == null })
model.addAttribute("labs", api.listLabs(t, id))
return "admin/course"
}
@PostMapping("/{id}/groups")
fun attachGroup(
@PathVariable id: String,
@ModelAttribute form: CourseGroupForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.attachCourseGroup(token(session), id, CourseGroupPayload(requireGroupId(form)))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/admin/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
}
@PostMapping("/{id}/groups/{groupId}/detach")
fun detachGroup(
@PathVariable id: String,
@PathVariable groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.detachCourseGroup(token(session), id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/admin/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
}
@PostMapping("/{id}/teachers")
fun assignTeacher(
@PathVariable id: String,
@ModelAttribute form: CourseTeacherForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignCourseTeacher(token(session), id, CourseTeacherPayload(requireAccountId(form)))
redirect.addFlashAttribute("notice", "Преподаватель назначен на курс")
"redirect:/admin/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
}
@PostMapping("/{id}/teachers/{accountId}/remove")
fun removeTeacher(
@PathVariable id: String,
@PathVariable accountId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.removeCourseTeacher(token(session), id, accountId)
redirect.addFlashAttribute("notice", "Преподаватель снят с курса")
"redirect:/admin/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
}
@PostMapping("/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestParam status: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val normalized = normalizeStatus(status)
api.updateCourse(token(session), id, CourseUpdatePayload(status = normalized))
val label = if (normalized == "ARCHIVED") "Курс перемещён в архив" else "Курс возвращён в работу"
redirect.addFlashAttribute("notice", label)
"redirect:/admin/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
}
private fun requireGroupId(form: CourseGroupForm): String = form.groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
private fun requireAccountId(form: CourseTeacherForm): String =
form.accountId.trim().ifBlank { throw ApiException(400, "Укажите идентификатор преподавателя") }
private fun normalizeStatus(value: String): String {
val status = value.trim().uppercase()
if (status !in COURSE_STATUSES) throw ApiException(400, "Недопустимый статус курса")
return status
}
private companion object {
val COURSE_STATUSES = setOf("ACTIVE", "ARCHIVED")
}
}

View File

@@ -47,6 +47,25 @@ data class CreateLabForm(
val status: String = "",
val descriptionMd: String = "",
val levels: String = "",
val courseId: String? = null,
)
data class LabCopyForm(
val courseId: String = "",
val number: Int? = null,
val title: String? = null,
)
data class CreateCourseForm(
val title: String? = null,
)
data class CourseTeacherForm(
val accountId: String = "",
)
data class CourseGroupForm(
val groupId: String = "",
)
data class SaveLevelForm(

View File

@@ -15,6 +15,7 @@ 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.AssignVariantPayload
import ru.ysu.lms.web.api.LabCopyPayload
import ru.ysu.lms.web.api.LabCreatePayload
import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.LevelAssignPayload
@@ -23,6 +24,7 @@ import ru.ysu.lms.web.api.LevelSavePayload
import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.addLevel
import ru.ysu.lms.web.api.assignVariant
import ru.ysu.lms.web.api.copyLab
import ru.ysu.lms.web.api.createLab
import ru.ysu.lms.web.api.deleteLab
import ru.ysu.lms.web.api.deleteLevel
@@ -31,6 +33,7 @@ import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.importLab
import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.labSubmissions
import ru.ysu.lms.web.api.listCourses
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.renameLevel
import ru.ysu.lms.web.api.saveVariant
@@ -48,16 +51,22 @@ class AdminLabController(
) : AdminSupport(api, markdown, mapper) {
@GetMapping("/labs")
fun labs(
@RequestParam(required = false) courseId: String?,
session: HttpSession,
model: Model,
): String {
model.addAttribute("labs", api.listLabs(token(session)))
val t = token(session)
val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
model.addAttribute("labs", api.listLabs(t, selectedCourseId))
model.addAttribute("courses", api.listCourses(t))
model.addAttribute("selectedCourseId", selectedCourseId)
return "admin/labs"
}
@PostMapping("/labs/import")
fun importLab(
@RequestParam("file") file: MultipartFile,
@RequestParam(required = false) courseId: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -66,6 +75,7 @@ class AdminLabController(
val result =
api.importLab(
token(session),
courseId?.trim()?.takeIf { it.isNotBlank() },
file.originalFilename ?: "lab.zip",
file.bytes,
)
@@ -133,6 +143,7 @@ class AdminLabController(
val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 }
model.addAttribute("lab", lab)
model.addAttribute("courses", api.listCourses(t))
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute(
"deadlineMsk",
@@ -218,6 +229,7 @@ class AdminLabController(
status = form.status,
descriptionMd = form.descriptionMd,
levels = levelList,
courseId = form.courseId?.trim()?.takeIf { it.isNotBlank() },
),
)
redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана")
@@ -227,6 +239,32 @@ class AdminLabController(
"redirect:/admin/labs"
}
@PostMapping("/labs/{id}/copy")
fun copyLab(
@PathVariable id: String,
@ModelAttribute form: LabCopyForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val targetCourseId = form.courseId.trim().ifBlank { throw ApiException(400, "Выберите целевой курс") }
val created =
api.copyLab(
token(session),
id,
LabCopyPayload(
courseId = targetCourseId,
number = form.number,
title = form.title?.trim()?.takeIf { it.isNotBlank() },
),
)
redirect.addFlashAttribute("notice", "Лабораторная скопирована: ЛР ${created.number}. ${created.title}")
"redirect:/admin/labs/${created.id}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$id"
}
@PostMapping("/labs/{labId}/levels/save")
fun saveLevel(
@PathVariable labId: String,

View File

@@ -0,0 +1,152 @@
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
fun ApiClient.listCourses(token: String): List<CourseDto> {
val type = object : ParameterizedTypeReference<List<CourseDto>>() {}
return call {
client
.get()
.uri("/api/v1/courses")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.createCourse(
token: String,
payload: CourseCreatePayload,
): CourseDetailDto =
call {
client
.post()
.uri("/api/v1/courses")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.courseDetail(
token: String,
courseId: String,
): CourseDetailDto =
call {
client
.get()
.uri("/api/v1/courses/{id}", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.updateCourse(
token: String,
courseId: String,
payload: CourseUpdatePayload,
): CourseDetailDto =
call {
client
.put()
.uri("/api/v1/courses/{id}", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.assignCourseTeacher(
token: String,
courseId: String,
payload: CourseTeacherPayload,
): CourseDetailDto =
call {
client
.post()
.uri("/api/v1/courses/{id}/teachers", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.removeCourseTeacher(
token: String,
courseId: String,
accountId: String,
): CourseDetailDto =
call {
client
.delete()
.uri("/api/v1/courses/{id}/teachers/{accountId}", courseId, accountId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.attachCourseGroup(
token: String,
courseId: String,
payload: CourseGroupPayload,
): CourseDetailDto =
call {
client
.post()
.uri("/api/v1/courses/{id}/groups", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.detachCourseGroup(
token: String,
courseId: String,
groupId: String,
): CourseDetailDto =
call {
client
.delete()
.uri("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(CourseDetailDto::class.java)
}
fun ApiClient.bootstrapCourses(
token: String,
payload: CourseBootstrapPayload,
): CourseBootstrapResultDto =
call {
client
.post()
.uri("/api/v1/courses/bootstrap")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(CourseBootstrapResultDto::class.java)
}
fun ApiClient.copyLab(
token: String,
labId: String,
payload: LabCopyPayload,
): LabDetailDto =
call {
client
.post()
.uri("/api/v1/labs/{id}/copy", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(LabDetailDto::class.java)
}

View File

@@ -6,13 +6,19 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.util.LinkedMultiValueMap
fun ApiClient.listLabs(token: String): List<LabSummaryDto> {
fun ApiClient.listLabs(
token: String,
courseId: String? = null,
): List<LabSummaryDto> {
val type = object : ParameterizedTypeReference<List<LabSummaryDto>>() {}
return call {
client
.get()
.uri("/api/v1/labs")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder.path("/api/v1/labs")
if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId)
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
@@ -33,6 +39,7 @@ fun ApiClient.labDetail(
fun ApiClient.importLab(
token: String,
courseId: String?,
filename: String,
bytes: ByteArray,
): LabImportResultDto {
@@ -46,8 +53,11 @@ fun ApiClient.importLab(
return call {
client
.post()
.uri("/api/v1/labs/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder.path("/api/v1/labs/import")
if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId)
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve()

View File

@@ -158,6 +158,9 @@ data class LabSummaryDto(
val defenseAt: String? = null,
val pointsMax: Any? = null,
val variantCount: Int = 0,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -250,6 +253,88 @@ data class LabDetailDto(
val defenseAt: String? = null,
val pointsMax: Any? = null,
val levels: List<LabLevelDetailDto> = emptyList(),
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseDto(
val id: String = "",
val courseNo: Long = 0,
val title: String = "",
val ownerAccountId: String = "",
val status: String = "ACTIVE",
val teacherCount: Long = 0,
val groupCount: Long = 0,
val labCount: Long = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseTeacherDto(
val accountId: String = "",
val assignedAt: String = "",
val removedAt: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupDto(
val groupId: String = "",
val addedAt: String = "",
val removedAt: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseDetailDto(
val id: String = "",
val courseNo: Long = 0,
val title: String = "",
val ownerAccountId: String = "",
val status: String = "ACTIVE",
val createdAt: String = "",
val updatedAt: String = "",
val teachers: List<CourseTeacherDto> = emptyList(),
val groups: List<CourseGroupDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseBootstrapResultDto(
val courseId: String = "",
val courseNo: Long = 0,
val title: String = "",
val attachedGroups: Int = 0,
val assignedLabs: Int = 0,
val assignedChapters: Int = 0,
val labsWithoutCourse: Long = 0,
val chaptersWithoutCourse: Long = 0,
)
data class CourseCreatePayload(
val title: String? = null,
)
data class CourseUpdatePayload(
val title: String? = null,
val status: String? = null,
)
data class CourseTeacherPayload(
val accountId: String,
)
data class CourseGroupPayload(
val groupId: String,
)
data class CourseBootstrapPayload(
val groupIds: List<String> = emptyList(),
val title: String? = null,
)
data class LabCopyPayload(
val courseId: String,
val number: Int? = null,
val title: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -382,6 +467,7 @@ data class LabCreatePayload(
val status: String,
val descriptionMd: String,
val levels: List<LevelPayload>,
val courseId: String? = null,
)
data class LevelPayload(

View File

@@ -0,0 +1,138 @@
<!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="@{/admin/courses}" class="nav-link">← Все курсы</a>
<div class="page-head mb-2">
<div>
<h1 class="page-title" th:text="|Курс ${course.courseNo}. ${course.title}|">Курс 1</h1>
<div class="meta mb-2">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<form class="inline-form" th:action="@{/admin/courses/{id}/status(id=${course.id})}" method="post"
th:attr="data-confirm=${course.status == 'ACTIVE' ? 'Переместить курс в архив? Привязка групп и преподавателей станет недоступна.' : 'Вернуть курс в работу?'}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="status" th:value="${course.status == 'ACTIVE' ? 'ARCHIVED' : 'ACTIVE'}">
<button class="btn btn-sm" type="submit"
th:text="${course.status == 'ACTIVE' ? 'В архив' : 'Вернуть в работу'}">В архив</button>
</form>
<span th:text="|Владелец: ${course.ownerAccountId}|">Владелец: —</span>
</div>
<span th:text="|Создан: ${course.createdAt}|">Создан: —</span>
· <span th:text="|Обновлён: ${course.updatedAt}|">Обновлён: —</span>
</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><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="attached : ${attachedGroups}">
<td>
<a th:href="@{/admin/groups/{id}(id=${attached.groupId})}"
th:text="${groupNames[attached.groupId] ?: attached.groupId}">—</a>
</td>
<td class="text-muted" th:text="${attached.addedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/groups/{groupId}/detach(id=${course.id}, groupId=${attached.groupId})}"
method="post" data-confirm="Отвязать группу от курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отвязать</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(attachedGroups)}">
<td colspan="3" class="empty">Группы к курсу не привязаны.</td>
</tr>
</tbody>
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/groups(id=${course.id})}"
method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
th:disabled="${#lists.isEmpty(availableGroups)}">
<option value="">— выберите группу —</option>
<option th:each="group : ${availableGroups}" th:value="${group.id}"
th:text="${group.name}">—</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(availableGroups)}">Все доступные группы уже привязаны.</p>
</div>
<button class="btn btn-primary" type="submit"
th:disabled="${#lists.isEmpty(availableGroups)}">Привязать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="col-lg-6">
<h2 class="section-title">Преподаватели</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Аккаунт</th><th>Назначен</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="teacher : ${activeTeachers}">
<td class="font-mono" th:text="${teacher.accountId}">—</td>
<td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/teachers/{accountId}/remove(id=${course.id}, accountId=${teacher.accountId})}"
method="post" data-confirm="Снять преподавателя с курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Снять</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(activeTeachers)}">
<td colspan="3" class="empty">Преподаватели не назначены.</td>
</tr>
</tbody>
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/teachers(id=${course.id})}"
method="post">
<div><label class="label" for="teacherAccount">Идентификатор аккаунта преподавателя</label>
<input class="input font-mono" id="teacherAccount" name="accountId" required
placeholder="UUID аккаунта"></div>
<p class="hint">UUID можно скопировать на странице «Пользователи».</p>
<button class="btn btn-primary" type="submit">Назначить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mt-4 mb-2">
<h2 class="section-title m-0">Лабораторные курса</h2>
<a class="text-sm" th:href="@{/admin/labs(courseId=${course.id})}">Открыть в списке лабораторных →</a>
</div>
<div class="card card-flush">
<table>
<thead><tr><th>№</th><th>Название</th><th data-filter>Статус</th><th>Вариантов</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></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-center">
<a class="btn btn-sm btn-secondary" th:href="@{/admin/labs/{id}(id=${lab.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">В курсе пока нет лабораторных.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!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">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="page-title m-0">Курсы</h1>
<div class="flex gap-2">
<input class="input table-search" data-target="courses-table"
placeholder="Поиск по названию…" aria-label="Поиск по курсам">
<button class="btn btn-primary" type="button" data-modal="modal-create-course">+ Новый курс</button>
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="card card-body mb-6">
<h2 class="section-title">Первичная настройка курса</h2>
<p class="text-sm text-muted">
Курсов ещё нет. Bootstrap создаст курс «Промышленное программирование на языке C»,
перенесёт в него все лабораторные и главы и привяжет все активные группы
(<span th:text="${activeGroupCount}">0</span>).
</p>
<form class="form-stack" th:action="@{/admin/courses/bootstrap}" method="post"
data-confirm="Создать курс и перенести в него все лабораторные, главы и активные группы?">
<div><label class="label" for="bootstrapTitle">Название курса (необязательно)</label>
<input class="input" id="bootstrapTitle" name="title"
placeholder="Промышленное программирование на языке C"></div>
<button class="btn btn-primary" type="submit">Создать курс и перенести данные</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="card card-flush">
<table id="courses-table" class="tablekit">
<thead>
<tr>
<th class="th-sortable is-asc" title="Номер — по возрастанию">№</th>
<th class="th-sortable" title="Название — по возрастанию">Название</th>
<th data-filter>Статус</th>
<th>Преподавателей</th>
<th>Групп</th>
<th>Лабораторных</th>
<th data-nosort></th>
</tr>
</thead>
<tbody>
<tr th:each="course : ${courses}">
<td th:text="${course.courseNo}">1</td>
<td><a th:href="@{/admin/courses/{id}(id=${course.id})}" th:text="${course.title}">—</a></td>
<td th:attr="data-filter-value=${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
</td>
<td th:text="${course.teacherCount}">0</td>
<td th:text="${course.groupCount}">0</td>
<td th:text="${course.labCount}">0</td>
<td class="text-center">
<a class="btn btn-sm btn-secondary" th:href="@{/admin/courses/{id}(id=${course.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(courses)}"><td colspan="7" class="empty">Курсов пока нет.</td></tr>
</tbody>
</table>
</div>
<dialog id="modal-create-course" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новый курс</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/courses}" method="post">
<div><label class="label" for="courseTitle">Название</label>
<input class="input" id="courseTitle" name="title" placeholder="Новый курс" autofocus></div>
<p class="hint">Номер курса присвоится автоматически. Название можно оставить пустым —
тогда будет использовано «Новый курс».</p>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</div>
</body>
</html>

View File

@@ -19,6 +19,13 @@
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать
</button>
</form>
<button class="btn btn-secondary btn-sm" type="button" data-modal="modal-copy-lab">Копировать в курс</button>
</div>
<div class="mb-2">
<span th:if="${lab.courseId != null}">Курс:
<a th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a></span>
<span th:if="${lab.courseId == null}" class="text-muted">Курс не указан</span>
</div>
<span th:if="${deadlineMsk != null}">Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
@@ -196,6 +203,36 @@
</dialog>
</th:block>
</div>
<dialog id="modal-copy-lab" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Копировать лабораторную в курс</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/copy(id=${lab.id})}" method="post">
<div><label class="label" for="copyCourseId">Целевой курс</label>
<select class="input" id="copyCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:if="${lab.courseId == null or course.id != lab.courseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
</div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="copyLabNumber">Номер (необязательно)</label>
<input class="input" id="copyLabNumber" name="number" type="number" min="1"
th:placeholder="${lab.number}"></div>
<div class="col-md-6"><label class="label" for="copyLabTitle">Название (необязательно)</label>
<input class="input" id="copyLabTitle" name="title" th:placeholder="${lab.title}"></div>
</div>
<p class="hint">Копируются условие, уровни и варианты. Сдачи, оценки и назначения студентов
не переносятся; копия создаётся черновиком.</p>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Копировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<script th:src="@{/js/admin-lab.js(v='37')}" defer></script>
</div>
</body>

View File

@@ -5,7 +5,16 @@
<div id="content">
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2 items-center">
<form method="get" th:action="@{/admin/labs}" class="m-0">
<select class="input" name="courseId" aria-label="Фильтр по курсу" onchange="this.form.submit()">
<option value="" th:selected="${selectedCourseId == null}">Все курсы</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:selected="${course.id == selectedCourseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<noscript><button class="btn btn-secondary" type="submit">Фильтр</button></noscript>
</form>
<input class="input table-search" data-target="labs-table"
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" data-modal="modal-import-lab">Импорт</button>
@@ -13,13 +22,25 @@
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="card card-body mb-6">
<p class="text-sm text-muted m-0">
Курсов ещё нет. Сначала создайте курс на странице
<a th:href="@{/admin/courses}">«Курсы»</a> — все лабораторные принадлежат курсу.
</p>
</div>
<div class="card card-flush">
<table id="labs-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Курс</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td>
<a th:if="${lab.courseId != null}" th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a>
<span th:if="${lab.courseId == null}" class="text-muted">—</span>
</td>
<td th:text="${lab.variantCount}">0</td>
<td th:attr="data-filter-value=${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">
<span class="badge"
@@ -43,7 +64,7 @@
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="7" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
</table>
</div>
@@ -56,6 +77,14 @@
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
<div><label class="label" for="importCourseId">Курс</label>
<select class="input" id="importCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(courses)}">Сначала создайте курс на странице «Курсы».</p>
</div>
<div><label class="label" for="labFile">Файл</label>
<input class="input" id="labFile" type="file" name="file" accept=".zip" required></div>
<p class="hint">
@@ -66,7 +95,7 @@
<code class="code">deploy/IMPORT.md</code>.
</p>
<p class="hint">Даты сдачи и защиты после импорта задаются на карточке лабораторной.</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
@@ -79,6 +108,15 @@
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div><label class="label" for="createCourseId">Курс</label>
<select class="input" id="createCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:selected="${course.id == selectedCourseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(courses)}">Сначала создайте курс на странице «Курсы».</p>
</div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
@@ -103,7 +141,7 @@
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>

View File

@@ -49,6 +49,7 @@
<div class="offcanvas-body">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<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/users}">Пользователи</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>