feat(web): перевести браузерные URL курсов на courseNo

- teacher/admin course pages, content, gradebook, profile, downloads
- courseNo -> UUID только на границе REST API
- обновлены e2e, шаблоны, тесты и docs
- LDD-UI-002; baseline 59 claims / 385 тестов
This commit is contained in:
2026-10-29 19:51:03 +03:00
parent b57a5ffbce
commit 65ed08f5c2
45 changed files with 1058 additions and 352 deletions

View File

@@ -21,7 +21,7 @@ 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.courseByUrlNo
import ru.ysu.lms.web.api.createCourse
import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listCourses
@@ -61,7 +61,7 @@ class AdminCourseController(
try {
val course = api.createCourse(token(session), CourseCreatePayload(form.title?.trim()?.takeIf { it.isNotBlank() }))
redirect.addFlashAttribute("notice", "Курс №${course.courseNo} создан")
"redirect:/admin/courses/${course.id}"
"redirect:/admin/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses"
@@ -105,14 +105,14 @@ class AdminCourseController(
if (chaptersWithoutCourse > 0) append("; глав без курса: $chaptersWithoutCourse")
}
@GetMapping("/{id}")
@GetMapping("/{courseNo}")
fun detail(
@PathVariable id: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val course = api.courseDetail(t, id)
val course = api.courseByUrlNo(t, courseNo)
val groups = api.listGroups(t)
val attachedGroups = course.groups.filter { it.removedAt == null }
val attachedIds = attachedGroups.map { it.groupId }.toSet()
@@ -127,92 +127,110 @@ class AdminCourseController(
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, course.id))
return "admin/course"
}
@PostMapping("/{id}/groups")
@PostMapping("/{courseNo}/groups")
fun attachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@ModelAttribute form: CourseGroupForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.attachCourseGroup(token(session), id, CourseGroupPayload(requireGroupId(form)))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.attachCourseGroup(t, course.id, CourseGroupPayload(requireGroupId(form)))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/groups/{groupId}/detach")
@PostMapping("/{courseNo}/groups/{groupId}/detach")
fun detachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@PathVariable groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.detachCourseGroup(token(session), id, groupId)
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.detachCourseGroup(t, course.id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/teachers")
@PostMapping("/{courseNo}/teachers")
fun assignTeacher(
@PathVariable id: String,
@PathVariable courseNo: String,
@ModelAttribute form: CourseTeacherForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignCourseTeacher(token(session), id, CourseTeacherPayload(requireAccountId(form)))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.assignCourseTeacher(t, course.id, CourseTeacherPayload(requireAccountId(form)))
redirect.addFlashAttribute("notice", "Преподаватель назначен на курс")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/teachers/{accountId}/remove")
@PostMapping("/{courseNo}/teachers/{accountId}/remove")
fun removeTeacher(
@PathVariable id: String,
@PathVariable courseNo: String,
@PathVariable accountId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.removeCourseTeacher(token(session), id, accountId)
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.removeCourseTeacher(t, course.id, accountId)
redirect.addFlashAttribute("notice", "Преподаватель снят с курса")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/status")
@PostMapping("/{courseNo}/status")
fun updateStatus(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam status: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val normalized = normalizeStatus(status)
api.updateCourse(token(session), id, CourseUpdatePayload(status = normalized))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.updateCourse(t, course.id, CourseUpdatePayload(status = normalized))
val label = if (normalized == "ARCHIVED") "Курс перемещён в архив" else "Курс возвращён в работу"
redirect.addFlashAttribute("notice", label)
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
/** Возврат на страницу курса по человекочитаемому номеру; некорректный номер — в список курсов. */
private fun courseRedirect(courseNo: Long): String = "redirect:/admin/courses/$courseNo"
private fun courseRedirect(rawCourseNo: String): String {
val courseNo = rawCourseNo.trim().toLongOrNull() ?: return "redirect:/admin/courses"
return courseRedirect(courseNo)
}
private fun requireGroupId(form: CourseGroupForm): String = form.groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
private fun requireAccountId(form: CourseTeacherForm): String =

View File

@@ -62,17 +62,17 @@ class AdminDefenseController(
fun assignDefense(
@PathVariable labId: String,
@ModelAttribute form: DefenseForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
}
/** Назначение/правка индивидуального задания со страницы группы. */
@@ -80,17 +80,17 @@ class AdminDefenseController(
fun saveDefense(
@ModelAttribute form: DefenseForm,
@RequestParam labId: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
}
@PostMapping("/labs/{labId}/defense/import")
@@ -119,17 +119,17 @@ class AdminDefenseController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) groupId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val code = api.issueDefenseCode(token(session), id)
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
}
@Suppress("LongParameterList")
@@ -138,16 +138,16 @@ class AdminDefenseController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) groupId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.resetDefense(token(session), id)
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
}
}

View File

@@ -31,34 +31,34 @@ class AdminDistributeController(
fun setStudentLevel(
@PathVariable gid: String,
@ModelAttribute form: StudentLevelForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
@PostMapping("/groups/{gid}/pin-variant")
fun pinStudentVariant(
@PathVariable gid: String,
@ModelAttribute form: PinVariantForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
@Suppress("LongParameterList")
@@ -67,7 +67,7 @@ class AdminDistributeController(
@PathVariable gid: String,
@RequestParam labId: String,
@RequestParam(defaultValue = "false") force: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -78,10 +78,10 @@ class AdminDistributeController(
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
)
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
@@ -89,7 +89,7 @@ class AdminDistributeController(
fun distributeAllForGroup(
@PathVariable gid: String,
@RequestParam(defaultValue = "false") force: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -98,7 +98,7 @@ class AdminDistributeController(
val labs = api.listLabs(t).filter { it.status == "published" }
if (labs.isEmpty()) {
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
return groupRedirect(gid, courseId)
return groupRedirect(gid, courseNo)
}
val result =
api.distribute(
@@ -106,10 +106,10 @@ class AdminDistributeController(
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
)
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
/** Массовая установка уровня выделенным студентам. */

View File

@@ -19,6 +19,7 @@ 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.CourseDetailDto
import ru.ysu.lms.web.api.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeDto
@@ -32,7 +33,7 @@ import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate
@@ -163,25 +164,34 @@ class AdminGroupController(
@GetMapping("/groups/{id}")
fun groupDetail(
@PathVariable id: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val teacher = isTeacher(session)
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
val course = resolveCourse(t, courseNo)
val view =
if (teacher) {
teacherGroupView(t, id, effectiveCourseId)
teacherGroupView(t, id, course)
} else {
adminGroupView(t, id)
}
fillGroupModel(model, view)
model.addAttribute("teacherView", teacher)
model.addAttribute("profileCourseId", effectiveCourseId)
model.addAttribute("profileCourseNo", course?.courseNo)
return "admin/group"
}
private fun resolveCourse(
token: String,
rawCourseNo: String?,
): CourseDetailDto? =
rawCourseNo
?.trim()
?.takeIf { it.isNotBlank() }
?.let { api.courseByUrlNo(token, it) }
/** Администратор видит все группы и аттестации; преподаватель — только свой курс. */
private fun adminGroupView(
token: String,
@@ -203,11 +213,10 @@ class AdminGroupController(
private fun teacherGroupView(
token: String,
id: String,
courseId: String?,
course: CourseDetailDto?,
): GroupViewModel {
if (courseId == null) throw ApiException(400, "Не указан курс")
api.courseDetail(token, courseId)
val roster = api.courseGroupStudents(token, courseId, id)
if (course == null) throw ApiException(400, "Не указан курс")
val roster = api.courseGroupStudents(token, course.id, id)
val group =
GroupDetailDto(
id = id,
@@ -222,10 +231,10 @@ class AdminGroupController(
)
},
)
val labs = api.listLabs(token).filter { it.courseId == courseId && it.status == "published" }
val labs = api.listLabs(token).filter { it.courseId == course.id && it.status == "published" }
val labIds = labs.map { it.id }.toSet()
val memberIds = group.students.map { it.id }.toSet()
val grades = api.listGrades(token, courseId).filter { it.studentId in memberIds && it.labId in labIds }
val grades = api.listGrades(token, course.id).filter { it.studentId in memberIds && it.labId in labIds }
val dataByLab =
if (labs.isEmpty()) {
emptyMap()
@@ -414,13 +423,14 @@ class AdminGroupController(
fun assignModal(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher)
val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val lab = api.labDetail(t, labId)
val levels = api.getLevels(t, labId).associateBy { it.studentId }
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
@@ -446,12 +456,7 @@ class AdminGroupController(
model.addAttribute("groupId", id)
model.addAttribute("rows", rows)
model.addAttribute("variantUsage", variantUsage)
val courseQuery =
courseId
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "&courseId=$it" }
.orEmpty()
val courseQuery = course?.let { "&courseNo=${it.courseNo}" }.orEmpty()
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery")
return "admin/assign-fragment"
}
@@ -462,15 +467,12 @@ class AdminGroupController(
private fun groupStudentsFor(
token: String,
groupId: String,
courseId: String?,
course: CourseDetailDto?,
teacher: Boolean,
): List<StudentRowDto> {
if (!teacher) return api.groupDetail(token, groupId).students
val effectiveCourseId =
courseId?.trim()?.takeIf { it.isNotBlank() }
?: throw ApiException(400, "Не указан курс")
api.courseDetail(token, effectiveCourseId)
return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student ->
if (course == null) throw ApiException(400, "Не указан курс")
return api.courseGroupStudents(token, course.id, groupId).students.map { student ->
StudentRowDto(
id = student.id,
studentNo = student.studentNo,
@@ -496,7 +498,7 @@ class AdminGroupController(
fun setGrade(
@PathVariable id: String,
@ModelAttribute form: SetGradeForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -516,10 +518,10 @@ class AdminGroupController(
),
)
redirect.addFlashAttribute("notice", "Оценка сохранена")
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
}
/** Уровни и варианты лабораторной для модалки назначения варианта. */
@@ -578,15 +580,16 @@ class AdminGroupController(
fun downloadGroupSubmissions(
@PathVariable id: String,
@RequestParam(required = false) labId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
): ResponseEntity<ByteArray> {
val t = token(session)
val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher)
val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val studentById = students.associateBy { it.id }
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) }
val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == course?.id) }
val groupName = if (teacher) groupNameFor(t, id, course) else api.groupDetail(t, id).name
val bytes = buildGroupArchive(t, groupName, studentById, labs)
return zipAttachment("solutions-$groupName.zip", bytes)
}
@@ -595,10 +598,10 @@ class AdminGroupController(
private fun groupNameFor(
token: String,
groupId: String,
courseId: String?,
course: CourseDetailDto?,
): String {
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName }
val effectiveCourse = course ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourse.id, groupId).groupName }
.getOrDefault("")
.ifBlank { groupId }
}
@@ -610,19 +613,20 @@ class AdminGroupController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val t = token(session)
val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
val course = resolveCourse(t, courseNo)
val all = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
}
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
@@ -634,12 +638,13 @@ class AdminGroupController(
@RequestParam labId: String,
@RequestParam studentId: String,
@RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
): Map<String, Any> =
try {
val t = token(session)
val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
val course = resolveCourse(t, courseNo)
val memberIds = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
val memberIdSet = memberIds.toSet()
val state = api.publicationState(t, id, labId)
val current =

View File

@@ -26,6 +26,7 @@ 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.courseByUrlNo
import ru.ysu.lms.web.api.createLab
import ru.ysu.lms.web.api.deleteLab
import ru.ysu.lms.web.api.deleteLevel
@@ -55,15 +56,16 @@ class AdminLabController(
) : AdminSupport(api, markdown, mapper) {
@GetMapping("/labs")
fun labs(
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
model.addAttribute("labs", api.listLabs(t, selectedCourseId))
val selected = courseNo?.trim()?.takeIf { it.isNotBlank() }?.let { api.courseByUrlNo(t, it) }
model.addAttribute("labs", api.listLabs(t, selected?.id))
model.addAttribute("courses", coursesFor(session))
model.addAttribute("selectedCourseId", selectedCourseId)
model.addAttribute("selectedCourseNo", selected?.courseNo)
model.addAttribute("selectedCourseId", selected?.id)
return "admin/labs"
}
@@ -151,7 +153,7 @@ class AdminLabController(
model.addAttribute("courses", coursesFor(session))
model.addAttribute(
"courseUrl",
lab.courseId?.let { cid -> if (user?.isAdmin == true) "/admin/courses/$cid" else "/teacher/courses/$cid" },
lab.courseNo?.let { no -> if (user?.isAdmin == true) "/admin/courses/$no" else "/teacher/courses/$no" },
)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute(

View File

@@ -150,25 +150,25 @@ abstract class AdminSupport(
protected fun redirectToGroup(
groupId: String?,
labId: String?,
courseId: String? = null,
courseNo: String? = null,
): String =
when {
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseId)
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseNo)
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
else -> "redirect:/admin/groups"
}
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет номер курса. */
protected fun groupRedirect(
groupId: String,
courseId: String?,
): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}"
courseNo: String?,
): String = "redirect:/admin/groups/$groupId${courseQuery(courseNo)}"
private fun courseQuery(courseId: String?): String =
courseId
private fun courseQuery(courseNo: String?): String =
courseNo
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
?.let { "?courseNo=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
.orEmpty()
protected fun buildGroupArchive(

View File

@@ -57,6 +57,15 @@ fun ApiClient.courseDetail(
.body(CourseDetailDto::class.java)
}
/** Курс по номеру из браузерного URL; некорректный номер — 404, а не 500. */
fun ApiClient.courseByUrlNo(
token: String,
rawCourseNo: String,
): CourseDetailDto {
val courseNo = rawCourseNo.trim().toLongOrNull() ?: throw ApiException(404, "Курс не найден")
return courseByNo(token, courseNo)
}
/** Курс по человекочитаемому номеру из URL (`course_no`). */
fun ApiClient.courseByNo(
token: String,

View File

@@ -14,6 +14,7 @@ import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.StudentCourseProfileDto
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.downloadCourseSubmission
import ru.ysu.lms.web.api.studentProfile
import ru.ysu.lms.web.session.SessionKeys
@@ -26,32 +27,34 @@ class StudentProfileController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
@GetMapping("/teacher/courses/{courseId}/students/{studentId}")
@GetMapping("/teacher/courses/{courseNo}/students/{studentId}")
fun teacherProfile(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
): String = renderProfile(courseNo, studentId, session, model)
@GetMapping("/admin/courses/{courseId}/students/{studentId}")
@GetMapping("/admin/courses/{courseNo}/students/{studentId}")
fun adminProfile(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
): String = renderProfile(courseNo, studentId, session, model)
private fun renderProfile(
courseId: String,
courseNo: String,
studentId: String,
session: HttpSession,
model: Model,
): String {
val profile = api.studentProfile(token(session), courseId, studentId)
val token = token(session)
val course = api.courseByUrlNo(token, courseNo)
val profile = api.studentProfile(token, course.id, studentId)
prepareProfile(profile)
val isAdmin = sessionUser(session)?.isAdmin == true
val courseUrl = if (isAdmin) "/admin/courses/$courseId" else "/teacher/courses/$courseId"
val courseUrl = if (isAdmin) "/admin/courses/${course.courseNo}" else "/teacher/courses/${course.courseNo}"
model.addAttribute("profile", profile)
model.addAttribute("isAdmin", isAdmin)
model.addAttribute("courseUrl", courseUrl)
@@ -61,37 +64,39 @@ class StudentProfileController(
model.addAttribute(
"downloadBase",
if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download"
"/admin/courses/{courseNo}/submissions/{id}/download"
} else {
"/teacher/courses/{courseId}/submissions/{id}/download"
"/teacher/courses/{courseNo}/submissions/{id}/download"
},
)
return "profile/student"
}
@GetMapping("/teacher/courses/{courseId}/submissions/{id}/download")
@GetMapping("/teacher/courses/{courseNo}/submissions/{id}/download")
fun teacherDownload(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
): ResponseEntity<ByteArray> = downloadSubmission(courseNo, id, form, session)
@GetMapping("/admin/courses/{courseId}/submissions/{id}/download")
@GetMapping("/admin/courses/{courseNo}/submissions/{id}/download")
fun adminDownload(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
): ResponseEntity<ByteArray> = downloadSubmission(courseNo, id, form, session)
private fun downloadSubmission(
courseId: String,
courseNo: String,
id: String,
form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> {
val file = api.downloadCourseSubmission(token(session), courseId, id)
val token = token(session)
val course = api.courseByUrlNo(token, courseNo)
val file = api.downloadCourseSubmission(token, course.id, id)
val filename = submissionFilename(form) ?: file.filename.ifBlank { "submission-$id.zip" }
val encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
return ResponseEntity

View File

@@ -15,7 +15,7 @@ import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseAvailableGroups
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.courseGroups
import ru.ysu.lms.web.api.detachCourseGroup
@@ -49,24 +49,27 @@ class TeacherController(
return "teacher/dashboard"
}
@GetMapping("/courses/{id}")
@GetMapping("/courses/{courseNo}")
fun course(
@PathVariable id: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val course = api.courseDetail(token, id)
val course = api.courseByUrlNo(token, courseNo)
val groups = course.groups.filter { it.removedAt == null }
// Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет id.
val attachedGroups = runCatching { api.courseGroups(token, id) }.getOrDefault(emptyList())
val attachedGroups = runCatching { api.courseGroups(token, course.id) }.getOrDefault(emptyList())
model.addAttribute("course", course)
model.addAttribute("groups", groups)
model.addAttribute("groupNames", attachedGroups.associate { it.id to it.name.ifBlank { it.id } })
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
model.addAttribute(
"availableGroups",
runCatching { api.courseAvailableGroups(token, course.id) }.getOrDefault(emptyList()),
)
model.addAttribute(
"labs",
api.listLabs(token, id).map { lab ->
api.listLabs(token, course.id).map { lab ->
LabRow(
id = lab.id,
number = lab.number,
@@ -81,53 +84,56 @@ class TeacherController(
return "teacher/course"
}
@PostMapping("/courses/{id}/groups/attach")
@PostMapping("/courses/{courseNo}/groups/attach")
fun attachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = api.courseByUrlNo(token, courseNo)
return try {
val target = groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
api.attachCourseGroup(token, id, CourseGroupPayload(target))
api.attachCourseGroup(token, course.id, CourseGroupPayload(target))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
}
}
@PostMapping("/courses/{id}/groups/detach")
@PostMapping("/courses/{courseNo}/groups/detach")
fun detachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = api.courseByUrlNo(token, courseNo)
return try {
api.detachCourseGroup(token, id, groupId)
api.detachCourseGroup(token, course.id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
}
}
@GetMapping("/courses/{courseId}/groups/{groupId}")
@GetMapping("/courses/{courseNo}/groups/{groupId}")
fun group(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable groupId: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val roster = api.courseGroupStudents(token, courseId, groupId)
model.addAttribute("courseId", courseId)
val course = api.courseByUrlNo(token, courseNo)
val roster = api.courseGroupStudents(token, course.id, groupId)
model.addAttribute("courseNo", course.courseNo)
model.addAttribute("groupId", groupId)
model.addAttribute("roster", roster)
return "teacher/group"

View File

@@ -12,10 +12,11 @@ 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.CourseDetailDto
import ru.ysu.lms.web.api.CreateArticlePayload
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseContentArticle
import ru.ysu.lms.web.api.courseContentTree
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.createCourseArticle
import ru.ysu.lms.web.api.createCourseChapter
import ru.ysu.lms.web.api.createCourseSection
@@ -33,22 +34,23 @@ import ru.ysu.lms.web.session.SessionKeys
* каждое действие — обычный POST с редиректом назад на страницу курса.
*/
@Controller
@RequestMapping("/teacher/courses/{courseId}/content")
@RequestMapping("/teacher/courses/{courseNo}/content")
class TeacherCourseContentController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
@GetMapping
fun content(
@PathVariable courseId: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
model.addAttribute("course", api.courseDetail(token, courseId))
model.addAttribute("tree", api.courseContentTree(token, courseId))
model.addAttribute("course", course)
model.addAttribute("tree", api.courseContentTree(token, course.id))
// Рендерер отдаём в шаблон, чтобы предпросмотр статей всегда совпадал с тем,
// что увидит студент (та же санитизация markdown).
model.addAttribute("markdown", markdown)
@@ -61,64 +63,67 @@ class TeacherCourseContentController(
@PostMapping("/chapters")
fun createChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseChapter(token, courseId, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() })
api.createCourseChapter(token, course.id, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() })
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/chapters/{id}/rename")
fun renameChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.renameCourseChapter(token, courseId, id, title.trim())
api.renameCourseChapter(token, course.id, id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/chapters/{id}/delete")
fun deleteChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseChapter(token, courseId, id)
api.deleteCourseChapter(token, course.id, id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/sections")
fun createSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam chapterId: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
@@ -126,64 +131,67 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseSection(
token,
courseId,
course.id,
chapterId,
title.trim(),
slug?.trim()?.takeIf { it.isNotBlank() },
)
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/sections/{id}/rename")
fun renameSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.renameCourseSection(token, courseId, id, title.trim())
api.renameCourseSection(token, course.id, id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/sections/{id}/delete")
fun deleteSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam(required = false) moveTo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseSection(token, courseId, id, moveTo)
api.deleteCourseSection(token, course.id, id, moveTo)
redirect.addFlashAttribute("notice", "Раздел удалён")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/articles")
fun createArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam sectionId: String,
@RequestParam title: String,
@RequestParam(defaultValue = "") bodyMd: String,
@@ -191,10 +199,11 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseArticle(
token,
courseId,
course.id,
CreateArticlePayload(
chapterId = null,
sectionId = sectionId,
@@ -205,17 +214,17 @@ class TeacherCourseContentController(
),
)
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
@@ -224,23 +233,24 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
val dto = api.courseContentArticle(token, courseId, chapter, section, article)
model.addAttribute("course", api.courseDetail(token, courseId))
val dto = api.courseContentArticle(token, course.id, chapter, section, article)
model.addAttribute("course", course)
model.addAttribute("article", dto)
model.addAttribute("bodyHtml", markdown.render(dto.bodyMd))
model.addAttribute("back", articlePath(courseId, chapter, section, article))
model.addAttribute("back", articlePath(course.courseNo, chapter, section, article))
"teacher/content-article"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/articles/{id}/save")
fun saveArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
@RequestParam(defaultValue = "") bodyMd: String,
@@ -249,39 +259,41 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.updateCourseArticle(token, courseId, id, title.trim(), bodyMd)
api.updateCourseArticle(token, course.id, id, title.trim(), bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена")
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
@PostMapping("/articles/{id}/delete")
fun deleteArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseArticle(token, courseId, id)
api.deleteCourseArticle(token, course.id, id)
redirect.addFlashAttribute("notice", "Статья удалена")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
@Suppress("LongParameterList")
@PostMapping("/{kind}/{id}/published")
fun published(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable kind: String,
@PathVariable id: String,
@RequestParam published: Boolean,
@@ -290,33 +302,47 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
if (kind !in setOf("chapters", "sections", "articles")) {
redirect.addFlashAttribute("error", "Неизвестный тип материала")
return redirectBack(back, contentPath(courseId))
return redirectBack(back, contentPath(course.courseNo))
}
return try {
api.setCourseContentPublished(token, courseId, kind, id, published)
api.setCourseContentPublished(token, course.id, kind, id, published)
redirect.addFlashAttribute(
"notice",
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
private fun contentPath(courseId: String): String = "/teacher/courses/$courseId/content"
/** Курс по номеру из браузерного URL; ошибка резолва — сообщение и возврат к списку курсов. */
private fun resolveCourse(
token: String,
courseNo: String,
redirect: RedirectAttributes,
): CourseDetailDto? =
try {
api.courseByUrlNo(token, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
null
}
private fun contentPath(courseNo: Long): String = "/teacher/courses/$courseNo/content"
private fun articlePath(
courseId: String,
courseNo: Long,
chapter: String,
section: String,
article: String,
): String = "${contentPath(courseId)}/$chapter/$section/$article"
): String = "${contentPath(courseNo)}/$chapter/$section/$article"
private fun redirectContent(courseId: String): String = "redirect:${contentPath(courseId)}"
private fun redirectContent(courseNo: Long): String = "redirect:${contentPath(courseNo)}"
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
private fun redirectBack(

View File

@@ -11,7 +11,7 @@
<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"
<form class="inline-form" th:action="@{/admin/courses/{courseNo}/status(courseNo=${course.courseNo})}" 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'}">
@@ -37,13 +37,13 @@
<tbody>
<tr th:each="attached : ${attachedGroups}">
<td>
<a th:href="@{/admin/groups/{id}(id=${attached.groupId}, courseId=${course.id})}"
<a th:href="@{/admin/groups/{id}(id=${attached.groupId}, courseNo=${course.courseNo})}"
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})}"
th:action="@{/admin/courses/{courseNo}/groups/{groupId}/detach(courseNo=${course.courseNo}, 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>
@@ -57,7 +57,7 @@
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/groups(id=${course.id})}"
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{courseNo}/groups(courseNo=${course.courseNo})}"
method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
@@ -89,7 +89,7 @@
<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})}"
th:action="@{/admin/courses/{courseNo}/teachers/{accountId}/remove(courseNo=${course.courseNo}, 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>
@@ -103,7 +103,7 @@
</table>
</div>
<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/{courseNo}/teachers(courseNo=${course.courseNo})}"
method="post">
<div><label class="label" for="teacherAccount">Преподаватель</label>
<select class="input" id="teacherAccount" name="accountId" required
@@ -133,7 +133,7 @@
<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>
<a class="text-sm" th:href="@{/admin/labs(courseNo=${course.courseNo})}">Открыть в списке лабораторных →</a>
</div>
<div class="card card-flush">
<table>

View File

@@ -45,7 +45,7 @@
<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><a th:href="@{/admin/courses/{courseNo}(courseNo=${course.courseNo})}" 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'}"
@@ -55,7 +55,7 @@
<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>
<a class="btn btn-sm btn-secondary" th:href="@{/admin/courses/{courseNo}(courseNo=${course.courseNo})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(courses)}"><td colspan="7" class="empty">Курсов пока нет.</td></tr>

View File

@@ -46,7 +46,7 @@
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url"
th:value="${profileCourseId != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
th:value="${profileCourseNo != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x">
@@ -60,14 +60,14 @@
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<button class="btn btn-secondary btn-sm p-2" type="button"
th:attr="data-url=${profileCourseId != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
th:attr="data-url=${profileCourseNo != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'">
@@ -81,7 +81,7 @@
</button>
</form>
<a class="btn btn-secondary btn-sm p-2"
th:href="${profileCourseId != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:href="${profileCourseNo != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
</div>
</div>
@@ -124,10 +124,10 @@
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<a class="student-name" th:if="${profileCourseId != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
<a class="student-name" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}"
th:text="${row.fullName}">—</a>
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
<div class="student-name" th:if="${profileCourseNo == null}" th:text="${row.fullName}">—</div>
<div class="student-no-line">
<span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
@@ -196,8 +196,8 @@
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
<a class="btn btn-secondary btn-sm" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}">Профиль</a>
<form th:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
@@ -258,7 +258,7 @@
<div class="dlg-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
@@ -333,7 +333,7 @@
<form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-random-student">
<input type="hidden" name="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level">
@@ -342,7 +342,7 @@
<form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-pin-student">
<input type="hidden" name="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id">
@@ -360,7 +360,7 @@
<div class="dlg-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="defense-student-select">Студент</label>
<select class="input" id="defense-student-select" name="studentId" required>
@@ -381,14 +381,14 @@
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
<form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button>

View File

@@ -7,10 +7,10 @@
<h1 class="page-title">Лабораторные</h1>
<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}"
<select class="input" name="courseNo" aria-label="Фильтр по курсу" onchange="this.form.submit()">
<option value="" th:selected="${selectedCourseNo == null}">Все курсы</option>
<option th:each="course : ${courses}" th:value="${course.courseNo}"
th:selected="${course.courseNo == selectedCourseNo}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<noscript><button class="btn btn-secondary" type="submit">Фильтр</button></noscript>
@@ -37,7 +37,7 @@
<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})}"
<a th:if="${lab.courseId != null}" th:href="@{/admin/courses/{courseNo}(courseNo=${lab.courseNo})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a>
<span th:if="${lab.courseId == null}" class="text-muted">—</span>
</td>

View File

@@ -194,7 +194,7 @@
</td>
<td class="text-right">
<a class="btn btn-secondary btn-sm"
th:href="@{${downloadBase}(courseId=${profile.course.id}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
th:href="@{${downloadBase}(courseNo=${profile.course.courseNo}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
</td>
</tr>
</tbody>

View File

@@ -4,9 +4,9 @@
<body>
<div id="content">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}" class="nav-link" th:text="${course.title}">Курс</a>
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}" class="nav-link" th:text="${course.title}">Курс</a>
<span class="nav-sep">/</span>
<a th:href="@{/teacher/courses/{courseId}/content(courseId=${course.id})}"
<a th:href="@{/teacher/courses/{no}/content(no=${course.courseNo})}"
class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<span th:text="${article.chapterTitle}">Глава</span>
@@ -38,7 +38,7 @@
Студенческий вид
</a>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/published(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/published(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!article.published}">
@@ -47,7 +47,7 @@
th:text="${article.published ? 'Скрыть от студентов' : 'Опубликовать'}">Опубликовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/delete(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/delete(no=${course.courseNo}, id=${article.id})}"
method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
@@ -58,7 +58,7 @@
<div class="row g-4">
<div class="col-12 col-lg-7">
<form class="card card-body form-stack"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/save(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/save(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">

View File

@@ -3,7 +3,7 @@
<head><title th:text="${course.title} + ' — материалы — LMS'">Материалы курса</title></head>
<body>
<div id="content">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}" class="nav-link">← К курсу</a>
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}" class="nav-link">← К курсу</a>
<div class="page-head mb-2">
<div>
@@ -21,7 +21,7 @@
</div>
<form class="card card-body form-stack mt-4"
th:action="@{/teacher/courses/{courseId}/content/chapters(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/chapters(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<h2 class="section-title m-0">Новая глава</h2>
<div>
@@ -51,17 +51,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/published(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/published(no=${course.courseNo}, id=${chapter.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${chapter.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/rename(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/rename(no=${course.courseNo}, id=${chapter.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" name="title" th:value="${chapter.title}" required
@@ -69,7 +69,7 @@
<button class="btn btn-sm btn-secondary" type="submit">Переименовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/delete(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/delete(no=${course.courseNo}, id=${chapter.id})}"
method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -91,17 +91,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/published(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/published(no=${course.courseNo}, id=${section.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${section.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/rename(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/rename(no=${course.courseNo}, id=${section.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" name="title" th:value="${section.title}" required
@@ -109,7 +109,7 @@
<button class="btn btn-sm btn-secondary" type="submit">Переименовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/delete(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/delete(no=${course.courseNo}, id=${section.id})}"
method="post" data-confirm="Удалить раздел со всеми статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -120,28 +120,28 @@
<ul class="sort-list text-sm mt-2">
<li th:each="article : ${section.articles}">
<a class="sort-title"
th:href="@{/teacher/courses/{courseId}/content/{chapter}/{section}/{article}(courseId=${course.id}, chapter=${chapter.slug}, section=${section.slug}, article=${article.slug})}"
th:href="@{/teacher/courses/{no}/content/{chapter}/{section}/{article}(no=${course.courseNo}, chapter=${chapter.slug}, section=${section.slug}, article=${article.slug})}"
th:text="${article.title}">Статья</a>
<span class="badge"
th:classappend="${article.published ? 'badge-ok' : 'badge-warn'}"
th:text="${article.published ? 'опубликовано' : 'скрыто'}">—</span>
<div class="sort-actions">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/published(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/published(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${article.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/delete(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/delete(no=${course.courseNo}, id=${article.id})}"
method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
</form>
</div>
@@ -150,7 +150,7 @@
</ul>
<form class="card card-body form-stack mt-2"
th:action="@{/teacher/courses/{courseId}/content/articles(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/articles(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="sectionId" th:value="${section.id}">
<div>
@@ -167,7 +167,7 @@
</div>
<form class="card card-body form-stack mt-4"
th:action="@{/teacher/courses/{courseId}/content/sections(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/sections(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="chapterId" th:value="${chapter.id}">
<div>

View File

@@ -16,7 +16,7 @@
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" th:href="@{/teacher/courses/{id}/content(id=${course.id})}">Материалы курса</a>
<a class="btn btn-sm btn-primary" th:href="@{/teacher/courses/{no}/content(no=${course.courseNo})}">Материалы курса</a>
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
</div>
</div>
@@ -42,18 +42,18 @@
class="font-mono"
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
<a th:unless="${currentUser.admin}"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
th:href="@{/teacher/courses/{no}/groups/{groupId}(no=${course.courseNo}, groupId=${group.groupId})}"
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
</td>
<td class="text-muted" th:text="${group.addedAt}">—</td>
<td th:unless="${currentUser.admin}" class="text-center">
<div class="flex flex-wrap items-center justify-center gap-2">
<a class="btn btn-sm btn-secondary"
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseId=${course.id})}">Ведомость</a>
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseNo=${course.courseNo})}">Ведомость</a>
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
th:href="@{/teacher/courses/{no}/groups/{groupId}(no=${course.courseNo}, groupId=${group.groupId})}">Студенты</a>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/groups/detach(courseId=${course.id})}"
th:action="@{/teacher/courses/{no}/groups/detach(no=${course.courseNo})}"
method="post" data-confirm="Отвязать группу от курса? Ведомость и данные группы останутся в системе.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupId" th:value="${group.groupId}">
@@ -70,7 +70,7 @@
</div>
<form class="card card-body form-stack mt-4" th:unless="${currentUser.admin}"
th:action="@{/teacher/courses/{id}/groups/attach(id=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/groups/attach(no=${course.courseNo})}" method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
th:disabled="${#lists.isEmpty(availableGroups)}">

View File

@@ -11,7 +11,7 @@
<div class="card h-100">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}"
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}"
th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
@@ -23,7 +23,7 @@
</div>
<div class="mt-4">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{id}(id=${course.id})}">Открыть курс</a>
th:href="@{/teacher/courses/{no}(no=${course.courseNo})}">Открыть курс</a>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<head><title th:text="${(#strings.isEmpty(roster.groupName) ? groupId : roster.groupName)} + ' — LMS'">Группа</title></head>
<body>
<div id="content">
<a th:href="@{/teacher/courses/{id}(id=${courseId})}" class="nav-link">← К курсу</a>
<a th:href="@{/teacher/courses/{no}(no=${courseNo})}" class="nav-link">← К курсу</a>
<div class="page-head mb-2">
<div>
@@ -33,7 +33,7 @@
<tr th:each="student : ${roster.students}">
<td>
<a class="font-medium"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}"
th:href="@{/teacher/courses/{no}/students/{studentId}(no=${courseNo}, studentId=${student.id})}"
th:text="${student.fullName}">—</a>
</td>
<td class="font-mono" th:text="${student.studentNo}">—</td>
@@ -44,7 +44,7 @@
</td>
<td class="text-right">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}">Открыть</a>
th:href="@{/teacher/courses/{no}/students/{studentId}(no=${courseNo}, studentId=${student.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(roster.students)}">

View File

@@ -0,0 +1,190 @@
package ru.ysu.lms.web.admin
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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 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.CourseCreatePayload
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.CourseTeacherDto
import ru.ysu.lms.web.api.CourseTeacherPayload
import ru.ysu.lms.web.api.CourseUpdatePayload
import ru.ysu.lms.web.api.GroupDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.TeacherDto
import ru.ysu.lms.web.api.assignCourseTeacher
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseByUrlNo
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.listGroups
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.updateCourse
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper
class AdminCourseControllerTest {
private val api = mockk<ApiClient>()
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
groups = listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z")),
teachers = listOf(CourseTeacherDto(accountId = "a1", assignedAt = "2026-01-01T00:00:00Z")),
)
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(AdminCourseController(api, MarkdownRenderer(), ObjectMapper()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientIdentityKt",
"ru.ysu.lms.web.api.ApiClientLabKt",
"ru.ysu.lms.web.api.ApiClientTeacherKt",
)
every { api.courseByUrlNo("token", "7") } returns course
every { api.listGroups("token") } returns listOf(GroupDto(id = "g1", name = "ПИ-21БО"))
every { api.listTeachers("token") } returns listOf(TeacherDto(id = "a1", login = "teacher", name = "Преподаватель"))
every { api.listLabs("token", "course-uuid") } returns emptyList<LabSummaryDto>()
every { api.createCourse("token", any()) } returns course
every { api.attachCourseGroup("token", "course-uuid", any()) } returns course
every { api.detachCourseGroup("token", "course-uuid", "g1") } returns course
every { api.assignCourseTeacher("token", "course-uuid", any()) } returns course
every { api.removeCourseTeacher("token", "course-uuid", "a1") } returns course
every { api.updateCourse("token", "course-uuid", any()) } returns course
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `detail page resolves course by number and calls api with uuid`() {
val model =
mockMvc
.perform(get("/admin/courses/7").session(session()))
.andExpect(status().isOk)
.andReturn()
.modelAndView!!
.model
assertThat((model["course"] as CourseDetailDto).courseNo).isEqualTo(7)
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.listLabs("token", "course-uuid") }
verify(exactly = 0) { api.courseDetail(any(), any()) }
}
@Test
fun `create redirects to course number instead of uuid`() {
mockMvc
.perform(post("/admin/courses").param("title", "Алгоритмы").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.createCourse("token", CourseCreatePayload("Алгоритмы")) }
}
@Test
fun `attach group resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/groups").param("groupId", "g1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.attachCourseGroup("token", "course-uuid", CourseGroupPayload("g1")) }
}
@Test
fun `detach group resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/groups/g1/detach").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.detachCourseGroup("token", "course-uuid", "g1") }
}
@Test
fun `assign teacher resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/teachers").param("accountId", "a1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.assignCourseTeacher("token", "course-uuid", CourseTeacherPayload("a1")) }
}
@Test
fun `remove teacher resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/teachers/a1/remove").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.removeCourseTeacher("token", "course-uuid", "a1") }
}
@Test
fun `status update resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/status").param("status", "ARCHIVED").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.updateCourse("token", "course-uuid", CourseUpdatePayload(status = "ARCHIVED")) }
}
@Test
fun `unknown course number redirects back to course list`() {
every { api.courseByUrlNo("token", "abc") } throws ApiException(404, "Курс не найден")
mockMvc
.perform(post("/admin/courses/abc/groups").param("groupId", "g1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses"))
}
private fun session(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
setAttribute(
SessionKeys.USER,
SessionUser(
id = "admin-id",
login = "admin",
role = "ADMIN",
name = "Администратор",
mustChangePassword = false,
),
)
}
}

View File

@@ -0,0 +1,130 @@
package ru.ysu.lms.web.admin
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseDto
import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupOptionDto
import ru.ysu.lms.web.api.CourseTeacherDto
import ru.ysu.lms.web.api.TeacherDto
import ru.ysu.lms.web.session.SessionUser
import java.util.Locale
class AdminCourseTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
status = "ACTIVE",
createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-01-02T00:00:00Z",
groups = listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z")),
teachers = listOf(CourseTeacherDto(accountId = "a1", assignedAt = "2026-01-01T00:00:00Z")),
)
@Test
fun `courses list links to course by human number`() {
val html =
render(
"admin/courses",
mapOf(
"courses" to
listOf(
CourseDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
status = "ACTIVE",
teacherCount = 1,
groupCount = 1,
labCount = 0,
),
),
"groups" to emptyList<Any>(),
"activeGroupCount" to 0,
),
)
assertThat(html).contains("href=\"/admin/courses/7\"")
assertThat(html).doesNotContain("course-uuid")
}
@Test
fun `course page forms and actions use human number`() {
val html =
render(
"admin/course",
mapOf(
"course" to course,
"attachedGroups" to course.groups,
"availableGroups" to listOf(CourseGroupOptionDto(id = "g2", name = "ПИ-22БО")),
"groupNames" to mapOf("g1" to "ПИ-21БО"),
"teachers" to listOf(TeacherDto(id = "a1", login = "teacher", name = "Преподаватель")),
"availableTeachers" to emptyList<Any>(),
"teacherNames" to mapOf("a1" to "Преподаватель (teacher)"),
"activeTeachers" to course.teachers,
"labs" to emptyList<Any>(),
),
)
assertThat(html).contains("/admin/courses/7/status")
assertThat(html).contains("/admin/courses/7/groups")
assertThat(html).contains("/admin/courses/7/groups/g1/detach")
assertThat(html).contains("/admin/courses/7/teachers")
assertThat(html).contains("/admin/courses/7/teachers/a1/remove")
assertThat(html).contains("/admin/groups/g1?courseNo=7")
assertThat(html).doesNotContain("/admin/groups/g1?courseId=")
assertThat(html).doesNotContain("/admin/courses/course-uuid")
}
private fun render(
template: String,
variables: Map<String, Any?>,
): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
context.setVariable("currentUser", sessionUser())
variables.forEach { (name, value) -> context.setVariable(name, value) }
return engine.process(template, context)
}
private fun sessionUser(): SessionUser =
SessionUser(
id = "admin-id",
login = "admin",
role = "ADMIN",
name = "Администратор",
mustChangePassword = false,
)
}

View File

@@ -0,0 +1,146 @@
package ru.ysu.lms.web.admin
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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 ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupRosterDto
import ru.ysu.lms.web.api.CourseGroupStudentDto
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.GroupLabDataDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.listAttestations
import ru.ysu.lms.web.api.listGrades
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper
class AdminGroupControllerTest {
private val api = mockk<ApiClient>()
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
)
private val roster =
CourseGroupRosterDto(
groupId = "g1",
groupName = "ПИ-21БО",
students =
listOf(
CourseGroupStudentDto(id = "s1", studentNo = "123456", fullName = "Иванов Иван"),
),
)
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(AdminGroupController(api, MarkdownRenderer(), ObjectMapper()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientAttestationKt",
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientGroupKt",
"ru.ysu.lms.web.api.ApiClientIdentityKt",
"ru.ysu.lms.web.api.ApiClientLabKt",
"ru.ysu.lms.web.api.ApiClientProgressKt",
)
every { api.courseByUrlNo("token", "7") } returns course
every { api.courseGroupStudents("token", "course-uuid", "g1") } returns roster
every { api.listLabs("token") } returns
listOf(
LabSummaryDto(id = "l1", number = 1, title = "Введение", status = "published", courseId = "course-uuid"),
LabSummaryDto(id = "l2", number = 2, title = "Чужая", status = "published", courseId = "other-course"),
)
every { api.listGrades("token", "course-uuid") } returns emptyList()
every { api.groupLabData("token", "g1", listOf("l1")) } returns GroupLabDataDto(groupId = "g1")
every { api.setPublication("token", PublicationSetPayload("g1", "l1", listOf("s1"))) } just runs
every { api.listAttestations("token", "g1") } returns emptyList()
every { api.listGrades("token") } returns emptyList()
every { api.groupDetail("token", "g1") } returns GroupDetailDto(id = "g1", name = "ПИ-21БО")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `teacher gradebook resolves course number and scopes api calls by uuid`() {
val model =
mockMvc
.perform(get("/admin/groups/g1").param("courseNo", "7").session(teacherSession()))
.andExpect(status().isOk)
.andReturn()
.modelAndView!!
.model
assertThat(model["profileCourseNo"]).isEqualTo(7L)
assertThat(model["teacherView"]).isEqualTo(true)
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.courseGroupStudents("token", "course-uuid", "g1") }
verify(exactly = 1) { api.listGrades("token", "course-uuid") }
verify(exactly = 1) { api.groupLabData("token", "g1", listOf("l1")) }
}
@Test
fun `publish redirects back with course number instead of uuid`() {
mockMvc
.perform(
post("/admin/groups/g1/publish")
.param("courseNo", "7")
.param("labId", "l1")
.param("published", "true")
.session(teacherSession()),
).andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/groups/g1?courseNo=7"))
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.courseGroupStudents("token", "course-uuid", "g1") }
verify(exactly = 1) { api.setPublication("token", PublicationSetPayload("g1", "l1", listOf("s1"))) }
}
private fun teacherSession(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
setAttribute(
SessionKeys.USER,
SessionUser(
id = "t1",
login = "teacher",
role = "TEACHER",
name = "Преподаватель",
mustChangePassword = false,
),
)
}
}

View File

@@ -197,7 +197,7 @@ class AdminStage4TemplateTest {
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseId" to null,
"profileCourseNo" to null,
"teacherView" to false,
)
@@ -243,7 +243,7 @@ class AdminStage4TemplateTest {
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseId" to null,
"profileCourseNo" to null,
"teacherView" to false,
"importResult" to
ImportResultDto(

View File

@@ -35,8 +35,10 @@ class TeacherGradebookTemplateTest {
assertThat(html).contains("action=\"/admin/groups/g1/grades\"")
assertThat(html).contains("action=\"/admin/defenses/save\"")
assertThat(html).contains("/admin/groups/g1/publish")
assertThat(html).contains("name=\"courseId\"")
assertThat(html).contains("value=\"c1\"")
assertThat(html).contains("name=\"courseNo\"")
assertThat(html).contains("value=\"1\"")
assertThat(html).contains("courseNo=1")
assertThat(html).doesNotContain("courseId")
}
@Test
@@ -62,8 +64,9 @@ class TeacherGradebookTemplateTest {
fun `teacher gradebook links profiles to teacher course`() {
val html = render()
assertThat(html).contains("/teacher/courses/c1/students/s1")
assertThat(html).doesNotContain("/admin/courses/c1/students/s1")
assertThat(html).contains("/teacher/courses/1/students/s1")
assertThat(html).doesNotContain("/admin/courses/1/students/s1")
assertThat(html).doesNotContain("/teacher/courses/c1/students/s1")
}
@Test
@@ -72,8 +75,9 @@ class TeacherGradebookTemplateTest {
assertThat(html).contains("/credentials")
assertThat(html).contains("/students/s1/reset-password")
assertThat(html).contains("/admin/courses/c1/students/s1")
assertThat(html).doesNotContain("/teacher/courses/c1/students/s1")
assertThat(html).contains("/admin/courses/1/students/s1")
assertThat(html).doesNotContain("/teacher/courses/1/students/s1")
assertThat(html).doesNotContain("courseId")
}
private fun render(teacherView: Boolean? = true): String {
@@ -103,7 +107,7 @@ class TeacherGradebookTemplateTest {
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
if (teacherView != null) context.setVariable("teacherView", teacherView)
context.setVariable("profileCourseId", "c1")
context.setVariable("profileCourseNo", 1L)
context.setVariable("group", group)
context.setVariable("labs", listOf(lab))
context.setVariable("candidates", emptyList<Any>())

View File

@@ -51,10 +51,13 @@ class StudentProfileTemplateTest {
assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время")
assertThat(html).contains("main.c")
assertThat(html).contains("/admin/courses/c1/submissions/s1/download")
assertThat(html).contains("/admin/courses/1/submissions/s1/download")
assertThat(html).contains("studentNo=123456")
assertThat(html).contains("attempt=2")
assertThat(html).contains("← Все курсы")
assertThat(html).contains("href=\"/admin/courses/1\"")
assertThat(html).doesNotContain("/admin/courses/c1")
assertThat(html).doesNotContain("/courses/c1/")
}
@Test
@@ -62,7 +65,10 @@ class StudentProfileTemplateTest {
val html = render(isAdmin = false)
assertThat(html).contains("← Мои курсы")
assertThat(html).contains("/teacher/courses/c1/submissions/s1/download")
assertThat(html).contains("/teacher/courses/1/submissions/s1/download")
assertThat(html).contains("href=\"/teacher/courses/1\"")
assertThat(html).doesNotContain("/teacher/courses/c1")
assertThat(html).doesNotContain("/courses/c1/")
}
private fun render(isAdmin: Boolean): String {
@@ -76,16 +82,16 @@ class StudentProfileTemplateTest {
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("profile", sampleProfile())
context.setVariable("isAdmin", isAdmin)
context.setVariable("courseUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1")
context.setVariable("backUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1")
context.setVariable("courseUrl", if (isAdmin) "/admin/courses/1" else "/teacher/courses/1")
context.setVariable("backUrl", if (isAdmin) "/admin/courses/1" else "/teacher/courses/1")
context.setVariable("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы")
context.setVariable("backTitle", "Вернуться к курсу")
context.setVariable(
"downloadBase",
if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download"
"/admin/courses/{courseNo}/submissions/{id}/download"
} else {
"/teacher/courses/{courseId}/submissions/{id}/download"
"/teacher/courses/{courseNo}/submissions/{id}/download"
},
)
return engine.process("profile/student", context)

View File

@@ -12,15 +12,18 @@ import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupOptionDto
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseAvailableGroups
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.courseGroups
import ru.ysu.lms.web.api.listLabs
@@ -38,7 +41,7 @@ class TeacherControllerCourseGroupsTest {
@BeforeEach
fun setUp() {
mockkStatic("ru.ysu.lms.web.api.ApiClientCourseKt", "ru.ysu.lms.web.api.ApiClientLabKt")
every { api.courseDetail("token", "c1") } returns
every { api.courseByUrlNo("token", "1") } returns
CourseDetailDto(
id = "c1",
courseNo = 1,
@@ -84,9 +87,26 @@ class TeacherControllerCourseGroupsTest {
verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) }
}
@Test
fun `attach redirects to course page by human number`() {
every { api.attachCourseGroup("token", "c1", CourseGroupPayload("g2")) } returns
CourseDetailDto(id = "c1", courseNo = 1, title = "Программирование")
val result =
mockMvc
.perform(
post("/teacher/courses/1/groups/attach")
.session(session())
.param("groupId", "g2"),
).andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher/courses/1")
verify(exactly = 1) { api.attachCourseGroup("token", "c1", CourseGroupPayload("g2")) }
}
private fun renderModel() =
mockMvc
.perform(get("/teacher/courses/c1").session(session()))
.perform(get("/teacher/courses/1").session(session()))
.andReturn()
.modelAndView!!
.model

View File

@@ -0,0 +1,102 @@
package ru.ysu.lms.web.teacher
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
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.ContentChapterNodeDto
import ru.ysu.lms.web.api.ContentTreeNodeDto
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseContentTree
import ru.ysu.lms.web.api.createCourseChapter
import ru.ysu.lms.web.session.SessionKeys
/** Браузерные URL материалов курса используют номер курса, а REST API — UUID курса. */
class TeacherCourseContentControllerTest {
private val api = mockk<ApiClient>()
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(TeacherCourseContentController(api, MarkdownRenderer()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientContentKt",
"ru.ysu.lms.web.api.ApiClientCourseContentKt",
)
every { api.courseByUrlNo("token", "42") } returns
CourseDetailDto(id = "c1", courseNo = 42, title = "Алгоритмы")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `content page resolves course by human number and keeps uuid for api`() {
every { api.courseContentTree("token", "c1") } returns ContentTreeNodeDto()
val result =
mockMvc
.perform(get("/teacher/courses/42/content").session(session()))
.andReturn()
assertThat(result.modelAndView!!.viewName).isEqualTo("teacher/content")
assertThat(result.modelAndView!!.model["course"])
.isEqualTo(CourseDetailDto(id = "c1", courseNo = 42, title = "Алгоритмы"))
verify(exactly = 1) { api.courseByUrlNo("token", "42") }
verify(exactly = 1) { api.courseContentTree("token", "c1") }
}
@Test
fun `chapter creation redirects back to content page by course number`() {
every { api.createCourseChapter("token", "c1", "Введение", null) } returns
ContentChapterNodeDto(id = "ch1", title = "Введение")
val result =
mockMvc
.perform(
post("/teacher/courses/42/content/chapters")
.session(session())
.param("title", "Введение"),
).andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher/courses/42/content")
verify(exactly = 1) { api.createCourseChapter("token", "c1", "Введение", null) }
}
@Test
fun `unknown course number redirects to teacher dashboard`() {
every { api.courseByUrlNo("token", "999") } throws ApiException(404, "Курс не найден")
val result =
mockMvc
.perform(get("/teacher/courses/999/content").session(session()))
.andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher")
}
private fun session(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
}
}

View File

@@ -71,7 +71,7 @@ class TeacherCourseContentTemplateTest {
assertThat(html).contains("Материалы курса")
assertThat(html).contains("Алгоритмы")
assertThat(html).contains("action=\"/teacher/courses/c1/content/chapters\"")
assertThat(html).contains("action=\"/teacher/courses/42/content/chapters\"")
assertThat(html).contains("name=\"title\"")
assertThat(html).contains("Введение")
assertThat(html).contains("Основы")
@@ -86,24 +86,24 @@ class TeacherCourseContentTemplateTest {
fun `content page links articles to teacher edit page and publishes every level`() {
val html = render("teacher/content")
assertThat(html).contains("/teacher/courses/c1/content/intro/basics/first-step")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/published")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/published")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/published")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/rename")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/rename")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/delete")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/delete")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/delete")
assertThat(html).contains("/teacher/courses/42/content/intro/basics/first-step")
assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/published")
assertThat(html).contains("/teacher/courses/42/content/sections/sec1/published")
assertThat(html).contains("/teacher/courses/42/content/articles/art1/published")
assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/rename")
assertThat(html).contains("/teacher/courses/42/content/sections/sec1/rename")
assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/delete")
assertThat(html).contains("/teacher/courses/42/content/sections/sec1/delete")
assertThat(html).contains("/teacher/courses/42/content/articles/art1/delete")
}
@Test
fun `content page renders article and section creation forms`() {
val html = render("teacher/content")
assertThat(html).contains("action=\"/teacher/courses/c1/content/sections\"")
assertThat(html).contains("action=\"/teacher/courses/42/content/sections\"")
assertThat(html).contains("name=\"chapterId\"")
assertThat(html).contains("action=\"/teacher/courses/c1/content/articles\"")
assertThat(html).contains("action=\"/teacher/courses/42/content/articles\"")
assertThat(html).contains("name=\"sectionId\"")
assertThat(html).contains("name=\"bodyMd\"")
assertThat(html).contains("Добавить статью")
@@ -130,16 +130,16 @@ class TeacherCourseContentTemplateTest {
mapOf(
"article" to article,
"bodyHtml" to "<p>Текст статьи</p>",
"back" to "/teacher/courses/c1/content/intro/basics/first-step",
"back" to "/teacher/courses/42/content/intro/basics/first-step",
),
)
assertThat(html).contains("name=\"bodyMd\"")
assertThat(html).contains("rows=\"18\"")
assertThat(html).contains("# Первый шаг")
assertThat(html).contains("action=\"/teacher/courses/c1/content/articles/art1/save\"")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/delete")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/published")
assertThat(html).contains("action=\"/teacher/courses/42/content/articles/art1/save\"")
assertThat(html).contains("/teacher/courses/42/content/articles/art1/delete")
assertThat(html).contains("/teacher/courses/42/content/articles/art1/published")
assertThat(html).contains("<p>Текст статьи</p>")
assertThat(html).contains("name=\"_csrf\"")
assertThat(html).contains("/learn/course/42/intro/basics/first-step")

View File

@@ -35,16 +35,27 @@ class TeacherCourseTemplateTest {
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("/admin/groups/g1")
assertThat(html).contains("courseId=c1")
assertThat(html).contains("courseNo=1")
assertThat(html).doesNotContain("courseId")
assertThat(html).contains("Ведомость")
assertThat(html).contains("/teacher/courses/c1/groups/detach")
assertThat(html).contains("/teacher/courses/1/groups/detach")
assertThat(html).doesNotContain("/teacher/courses/c1")
}
@Test
fun `course links use human course number instead of uuid`() {
val html = render()
assertThat(html).contains("/teacher/courses/1/content")
assertThat(html).contains("/teacher/courses/1/groups/g1")
assertThat(html).doesNotContain("/teacher/courses/c1")
}
@Test
fun `renders group attach form with available groups`() {
val html = render()
assertThat(html).contains("action=\"/teacher/courses/c1/groups/attach\"")
assertThat(html).contains("action=\"/teacher/courses/1/groups/attach\"")
assertThat(html).contains("name=\"groupId\"")
assertThat(html).contains("ПИ-22БО")
assertThat(html).contains("Привязать")

View File

@@ -34,8 +34,8 @@ class TeacherGroupTemplateTest {
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("Иванов Иван")
assertThat(html).contains("123456")
assertThat(html).contains("/teacher/courses/c1")
assertThat(html).contains("/teacher/courses/c1/students/s1")
assertThat(html).contains("/teacher/courses/42")
assertThat(html).contains("/teacher/courses/42/students/s1")
assertThat(html).contains("активен")
}
@@ -56,7 +56,7 @@ class TeacherGroupTemplateTest {
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("courseId", "c1")
context.setVariable("courseNo", 42L)
context.setVariable("groupId", "g1")
context.setVariable(
"roster",