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:
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** Массовая установка уровня выделенным студентам. */
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}">
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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("Привязать")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user