feat(web): страницы курсов и привязка лабораторных
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
152
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt
Normal file
152
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
138
web/src/main/resources/templates/admin/course.html
Normal file
138
web/src/main/resources/templates/admin/course.html
Normal 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>
|
||||
84
web/src/main/resources/templates/admin/courses.html
Normal file
84
web/src/main/resources/templates/admin/courses.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user