feat(web): ведомость преподавателя, материалы курса и аудит
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
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.AuditEventDto
|
||||||
|
import ru.ysu.lms.web.api.listAudit
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin/audit")
|
||||||
|
class AdminAuditController(
|
||||||
|
api: ApiClient,
|
||||||
|
markdown: MarkdownRenderer,
|
||||||
|
mapper: ObjectMapper,
|
||||||
|
) : AdminSupport(api, markdown, mapper) {
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@RequestParam(required = false) action: String?,
|
||||||
|
@RequestParam(required = false) limit: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT)
|
||||||
|
val events: List<AuditEventDto> =
|
||||||
|
try {
|
||||||
|
api.listAudit(token(session), pageSize, filter)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
model.addAttribute("error", ex.message)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
model.addAttribute("events", AuditViews.rows(events))
|
||||||
|
model.addAttribute("action", filter.orEmpty())
|
||||||
|
model.addAttribute("limit", pageSize)
|
||||||
|
model.addAttribute("actionOptions", AuditViews.actionOptions(filter))
|
||||||
|
return "admin/audit"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_LIMIT = 100
|
||||||
|
const val MAX_LIMIT = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,16 +62,17 @@ class AdminDefenseController(
|
|||||||
fun assignDefense(
|
fun assignDefense(
|
||||||
@PathVariable labId: String,
|
@PathVariable labId: String,
|
||||||
@ModelAttribute form: DefenseForm,
|
@ModelAttribute form: DefenseForm,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||||
redirectToGroup(form.groupId, labId)
|
redirectToGroup(form.groupId, labId, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
redirectToGroup(form.groupId, labId)
|
redirectToGroup(form.groupId, labId, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Назначение/правка индивидуального задания со страницы группы. */
|
/** Назначение/правка индивидуального задания со страницы группы. */
|
||||||
@@ -79,16 +80,17 @@ class AdminDefenseController(
|
|||||||
fun saveDefense(
|
fun saveDefense(
|
||||||
@ModelAttribute form: DefenseForm,
|
@ModelAttribute form: DefenseForm,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||||
redirectToGroup(form.groupId, labId)
|
redirectToGroup(form.groupId, labId, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
redirectToGroup(form.groupId, labId)
|
redirectToGroup(form.groupId, labId, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/labs/{labId}/defense/import")
|
@PostMapping("/labs/{labId}/defense/import")
|
||||||
@@ -111,37 +113,41 @@ class AdminDefenseController(
|
|||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/defenses/{id}/issue-code")
|
@PostMapping("/defenses/{id}/issue-code")
|
||||||
fun issueDefenseCode(
|
fun issueDefenseCode(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
@RequestParam(required = false) groupId: String?,
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
val code = api.issueDefenseCode(token(session), id)
|
val code = api.issueDefenseCode(token(session), id)
|
||||||
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
|
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
|
||||||
redirectToGroup(groupId, labId)
|
redirectToGroup(groupId, labId, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
redirectToGroup(groupId, labId)
|
redirectToGroup(groupId, labId, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/defenses/{id}/reset")
|
@PostMapping("/defenses/{id}/reset")
|
||||||
fun resetDefenseSession(
|
fun resetDefenseSession(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
@RequestParam(required = false) groupId: String?,
|
@RequestParam(required = false) groupId: String?,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.resetDefense(token(session), id)
|
api.resetDefense(token(session), id)
|
||||||
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
|
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
|
||||||
redirectToGroup(groupId, labId)
|
redirectToGroup(groupId, labId, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
redirectToGroup(groupId, labId)
|
redirectToGroup(groupId, labId, courseId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,39 +31,43 @@ class AdminDistributeController(
|
|||||||
fun setStudentLevel(
|
fun setStudentLevel(
|
||||||
@PathVariable gid: String,
|
@PathVariable gid: String,
|
||||||
@ModelAttribute form: StudentLevelForm,
|
@ModelAttribute form: StudentLevelForm,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
|
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
|
||||||
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
|
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/{gid}/pin-variant")
|
@PostMapping("/groups/{gid}/pin-variant")
|
||||||
fun pinStudentVariant(
|
fun pinStudentVariant(
|
||||||
@PathVariable gid: String,
|
@PathVariable gid: String,
|
||||||
@ModelAttribute form: PinVariantForm,
|
@ModelAttribute form: PinVariantForm,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
|
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
|
||||||
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
|
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/groups/{gid}/distribute")
|
@PostMapping("/groups/{gid}/distribute")
|
||||||
fun distributeForGroup(
|
fun distributeForGroup(
|
||||||
@PathVariable gid: String,
|
@PathVariable gid: String,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
@RequestParam(defaultValue = "false") force: Boolean,
|
@RequestParam(defaultValue = "false") force: Boolean,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
@@ -74,10 +78,10 @@ class AdminDistributeController(
|
|||||||
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
|
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
|
||||||
)
|
)
|
||||||
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
|
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
|
||||||
@@ -85,6 +89,7 @@ class AdminDistributeController(
|
|||||||
fun distributeAllForGroup(
|
fun distributeAllForGroup(
|
||||||
@PathVariable gid: String,
|
@PathVariable gid: String,
|
||||||
@RequestParam(defaultValue = "false") force: Boolean,
|
@RequestParam(defaultValue = "false") force: Boolean,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
@@ -93,7 +98,7 @@ class AdminDistributeController(
|
|||||||
val labs = api.listLabs(t).filter { it.status == "published" }
|
val labs = api.listLabs(t).filter { it.status == "published" }
|
||||||
if (labs.isEmpty()) {
|
if (labs.isEmpty()) {
|
||||||
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
|
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
|
||||||
return "redirect:/admin/groups/$gid"
|
return groupRedirect(gid, courseId)
|
||||||
}
|
}
|
||||||
val result =
|
val result =
|
||||||
api.distribute(
|
api.distribute(
|
||||||
@@ -101,10 +106,10 @@ class AdminDistributeController(
|
|||||||
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
|
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
|
||||||
)
|
)
|
||||||
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
|
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$gid"
|
groupRedirect(gid, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Массовая установка уровня выделенным студентам. */
|
/** Массовая установка уровня выделенным студентам. */
|
||||||
|
|||||||
@@ -21,14 +21,19 @@ import ru.ysu.lms.web.api.ApiClient
|
|||||||
import ru.ysu.lms.web.api.ApiException
|
import ru.ysu.lms.web.api.ApiException
|
||||||
import ru.ysu.lms.web.api.CreateGroupPayload
|
import ru.ysu.lms.web.api.CreateGroupPayload
|
||||||
import ru.ysu.lms.web.api.DefenseDto
|
import ru.ysu.lms.web.api.DefenseDto
|
||||||
|
import ru.ysu.lms.web.api.GradeDto
|
||||||
import ru.ysu.lms.web.api.GradeSetPayload
|
import ru.ysu.lms.web.api.GradeSetPayload
|
||||||
import ru.ysu.lms.web.api.GroupDetailDto
|
import ru.ysu.lms.web.api.GroupDetailDto
|
||||||
|
import ru.ysu.lms.web.api.LabDataDto
|
||||||
import ru.ysu.lms.web.api.LabSummaryDto
|
import ru.ysu.lms.web.api.LabSummaryDto
|
||||||
import ru.ysu.lms.web.api.PublicationSetPayload
|
import ru.ysu.lms.web.api.PublicationSetPayload
|
||||||
|
import ru.ysu.lms.web.api.PublicationStateDto
|
||||||
import ru.ysu.lms.web.api.StudentCandidateDto
|
import ru.ysu.lms.web.api.StudentCandidateDto
|
||||||
import ru.ysu.lms.web.api.StudentRowDto
|
import ru.ysu.lms.web.api.StudentRowDto
|
||||||
import ru.ysu.lms.web.api.SubmissionDto
|
import ru.ysu.lms.web.api.SubmissionDto
|
||||||
import ru.ysu.lms.web.api.VariantAssignmentResultDto
|
import ru.ysu.lms.web.api.VariantAssignmentResultDto
|
||||||
|
import ru.ysu.lms.web.api.courseDetail
|
||||||
|
import ru.ysu.lms.web.api.courseGroupStudents
|
||||||
import ru.ysu.lms.web.api.createGroup
|
import ru.ysu.lms.web.api.createGroup
|
||||||
import ru.ysu.lms.web.api.deleteCommentTemplate
|
import ru.ysu.lms.web.api.deleteCommentTemplate
|
||||||
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
||||||
@@ -48,6 +53,8 @@ import ru.ysu.lms.web.api.publicationState
|
|||||||
import ru.ysu.lms.web.api.setGrade
|
import ru.ysu.lms.web.api.setGrade
|
||||||
import ru.ysu.lms.web.api.setPublication
|
import ru.ysu.lms.web.api.setPublication
|
||||||
import ru.ysu.lms.web.api.suggestComments
|
import ru.ysu.lms.web.api.suggestComments
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
import tools.jackson.databind.ObjectMapper
|
import tools.jackson.databind.ObjectMapper
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
@@ -56,6 +63,7 @@ import java.nio.charset.StandardCharsets
|
|||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/admin")
|
@RequestMapping("/admin")
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
class AdminGroupController(
|
class AdminGroupController(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
markdown: MarkdownRenderer,
|
markdown: MarkdownRenderer,
|
||||||
@@ -153,26 +161,95 @@ class AdminGroupController(
|
|||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val allLabs = api.listLabs(t)
|
val teacher = isTeacher(session)
|
||||||
val group = api.groupDetail(t, id)
|
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val labs = allLabs.filter { it.status == "published" }
|
val view =
|
||||||
val attestations = loadAttestations(t, id)
|
if (teacher) {
|
||||||
val grades = api.listGrades(t)
|
teacherGroupView(t, id, effectiveCourseId)
|
||||||
|
} else {
|
||||||
|
adminGroupView(t, id)
|
||||||
|
}
|
||||||
|
fillGroupModel(model, view)
|
||||||
|
model.addAttribute("teacherView", teacher)
|
||||||
|
model.addAttribute("profileCourseId", effectiveCourseId)
|
||||||
|
return "admin/group"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Администратор видит все группы и аттестации; преподаватель — только свой курс. */
|
||||||
|
private fun adminGroupView(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
): GroupViewModel {
|
||||||
|
val labs = api.listLabs(token).filter { it.status == "published" }
|
||||||
|
val group = api.groupDetail(token, id)
|
||||||
|
val attestations = loadAttestations(token, id)
|
||||||
|
val grades = api.listGrades(token)
|
||||||
|
val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
|
||||||
|
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
|
||||||
|
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ведомость преподавателя: состав группы берётся из academic в рамках курса,
|
||||||
|
* чужие лабораторные и оценки отфильтровываются, админские разделы не запрашиваются.
|
||||||
|
*/
|
||||||
|
private fun teacherGroupView(
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
courseId: String?,
|
||||||
|
): GroupViewModel {
|
||||||
|
if (courseId == null) throw ApiException(400, "Не указан курс")
|
||||||
|
api.courseDetail(token, courseId)
|
||||||
|
val roster = api.courseGroupStudents(token, courseId, id)
|
||||||
|
val group =
|
||||||
|
GroupDetailDto(
|
||||||
|
id = id,
|
||||||
|
name = roster.groupName.ifBlank { id },
|
||||||
|
students =
|
||||||
|
roster.students.map { student ->
|
||||||
|
StudentRowDto(
|
||||||
|
id = student.id,
|
||||||
|
studentNo = student.studentNo,
|
||||||
|
fullName = student.fullName,
|
||||||
|
status = student.status,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val labs = api.listLabs(token).filter { it.courseId == courseId && 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 dataByLab =
|
||||||
|
if (labs.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
|
||||||
|
}
|
||||||
|
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
private fun groupViewModel(
|
||||||
|
group: GroupDetailDto,
|
||||||
|
labs: List<LabSummaryDto>,
|
||||||
|
grades: List<GradeDto>,
|
||||||
|
dataByLab: Map<String, LabDataDto>,
|
||||||
|
attestations: List<GroupAttestation>,
|
||||||
|
candidates: List<StudentCandidateDto>,
|
||||||
|
): GroupViewModel {
|
||||||
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
|
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
|
||||||
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
|
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
|
||||||
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
|
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
|
||||||
val memberIds = group.students.map { it.id }
|
val memberIds = group.students.map { it.id }
|
||||||
val memberIdSet = memberIds.toSet()
|
val memberIdSet = memberIds.toSet()
|
||||||
// Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу
|
|
||||||
val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId }
|
|
||||||
val publishedByLab =
|
val publishedByLab =
|
||||||
labs.associate { lab ->
|
labs.associate { lab ->
|
||||||
val state = dataByLab.getValue(lab.id).publication
|
val state = dataByLab[lab.id]?.publication ?: PublicationStateDto()
|
||||||
lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet()
|
lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet()
|
||||||
}
|
}
|
||||||
val labPublication =
|
val labPublication =
|
||||||
labs.associate { lab ->
|
labs.associate { lab ->
|
||||||
val effective = publishedByLab.getValue(lab.id)
|
val effective = publishedByLab[lab.id].orEmpty()
|
||||||
lab.id to
|
lab.id to
|
||||||
GroupLabPublication(
|
GroupLabPublication(
|
||||||
allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective },
|
allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective },
|
||||||
@@ -180,9 +257,12 @@ class AdminGroupController(
|
|||||||
total = memberIds.size,
|
total = memberIds.size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } }
|
val assignmentsBy =
|
||||||
val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } }
|
labs.associateWith { lab -> dataByLab[lab.id]?.assignments.orEmpty().associateBy { it.studentId } }
|
||||||
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
|
val submissionsBy =
|
||||||
|
labs.associateWith { lab -> dataByLab[lab.id]?.submissions.orEmpty().associateBy { it.studentId } }
|
||||||
|
val defensesBy =
|
||||||
|
labs.associateWith { lab -> dataByLab[lab.id]?.defenses.orEmpty().associateBy { it.studentId } }
|
||||||
|
|
||||||
val rows =
|
val rows =
|
||||||
GroupAttestationViews.attach(
|
GroupAttestationViews.attach(
|
||||||
@@ -199,17 +279,19 @@ class AdminGroupController(
|
|||||||
),
|
),
|
||||||
attestations,
|
attestations,
|
||||||
)
|
)
|
||||||
|
val submissionCounts =
|
||||||
fillGroupModel(
|
labs.associate { lab ->
|
||||||
model,
|
lab.id to (submissionsBy[lab]?.keys?.count { it in memberIdSet } ?: 0)
|
||||||
groupViewModel(
|
}
|
||||||
t,
|
return GroupViewModel(
|
||||||
id,
|
group,
|
||||||
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
|
labs,
|
||||||
),
|
candidates,
|
||||||
|
rows,
|
||||||
|
labPublication,
|
||||||
|
submissionCounts,
|
||||||
|
attestations,
|
||||||
)
|
)
|
||||||
model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() })
|
|
||||||
return "admin/group"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
@@ -296,40 +378,6 @@ class AdminGroupController(
|
|||||||
.toPlainString()
|
.toPlainString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class GroupRenderData(
|
|
||||||
val group: GroupDetailDto,
|
|
||||||
val labs: List<LabSummaryDto>,
|
|
||||||
val rows: List<GroupStudentRow>,
|
|
||||||
val labPublication: Map<String, GroupLabPublication>,
|
|
||||||
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
|
|
||||||
val attestations: List<GroupAttestation>,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun groupViewModel(
|
|
||||||
token: String,
|
|
||||||
id: String,
|
|
||||||
data: GroupRenderData,
|
|
||||||
): GroupViewModel {
|
|
||||||
val memberIds =
|
|
||||||
data.group.students
|
|
||||||
.map { it.id }
|
|
||||||
.toSet()
|
|
||||||
val submissionCounts =
|
|
||||||
data.labs.associate { lab ->
|
|
||||||
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
|
|
||||||
}
|
|
||||||
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
|
|
||||||
return GroupViewModel(
|
|
||||||
data.group,
|
|
||||||
data.labs,
|
|
||||||
candidates,
|
|
||||||
data.rows,
|
|
||||||
data.labPublication,
|
|
||||||
submissionCounts,
|
|
||||||
data.attestations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class GroupViewModel(
|
private data class GroupViewModel(
|
||||||
val group: GroupDetailDto,
|
val group: GroupDetailDto,
|
||||||
val labs: List<LabSummaryDto>,
|
val labs: List<LabSummaryDto>,
|
||||||
@@ -359,11 +407,13 @@ class AdminGroupController(
|
|||||||
fun assignModal(
|
fun assignModal(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val group = api.groupDetail(t, id)
|
val teacher = isTeacher(session)
|
||||||
|
val students = groupStudentsFor(t, id, courseId, teacher)
|
||||||
val lab = api.labDetail(t, labId)
|
val lab = api.labDetail(t, labId)
|
||||||
val levels = api.getLevels(t, labId).associateBy { it.studentId }
|
val levels = api.getLevels(t, labId).associateBy { it.studentId }
|
||||||
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
|
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
|
||||||
@@ -371,7 +421,7 @@ class AdminGroupController(
|
|||||||
val variantUsage = java.util.HashMap<String, Int>()
|
val variantUsage = java.util.HashMap<String, Int>()
|
||||||
assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
|
assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
|
||||||
val rows =
|
val rows =
|
||||||
group.students.map { s ->
|
students.map { s ->
|
||||||
val assignment = assignments[s.id]
|
val assignment = assignments[s.id]
|
||||||
AssignModalRow(
|
AssignModalRow(
|
||||||
studentId = s.id,
|
studentId = s.id,
|
||||||
@@ -389,10 +439,40 @@ class AdminGroupController(
|
|||||||
model.addAttribute("groupId", id)
|
model.addAttribute("groupId", id)
|
||||||
model.addAttribute("rows", rows)
|
model.addAttribute("rows", rows)
|
||||||
model.addAttribute("variantUsage", variantUsage)
|
model.addAttribute("variantUsage", variantUsage)
|
||||||
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId")
|
val courseQuery =
|
||||||
|
courseId
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "&courseId=$it" }
|
||||||
|
.orEmpty()
|
||||||
|
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery")
|
||||||
return "admin/assign-fragment"
|
return "admin/assign-fragment"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isTeacher(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isTeacher == true
|
||||||
|
|
||||||
|
/** Студенты группы: для преподавателя — состав курса из academic, для админа — карточка группы. */
|
||||||
|
private fun groupStudentsFor(
|
||||||
|
token: String,
|
||||||
|
groupId: String,
|
||||||
|
courseId: String?,
|
||||||
|
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 ->
|
||||||
|
StudentRowDto(
|
||||||
|
id = student.id,
|
||||||
|
studentNo = student.studentNo,
|
||||||
|
fullName = student.fullName,
|
||||||
|
status = student.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class AssignModalRow(
|
private data class AssignModalRow(
|
||||||
val studentId: String,
|
val studentId: String,
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
@@ -409,6 +489,7 @@ class AdminGroupController(
|
|||||||
fun setGrade(
|
fun setGrade(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@ModelAttribute form: SetGradeForm,
|
@ModelAttribute form: SetGradeForm,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
@@ -428,10 +509,10 @@ class AdminGroupController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
redirect.addFlashAttribute("notice", "Оценка сохранена")
|
redirect.addFlashAttribute("notice", "Оценка сохранена")
|
||||||
"redirect:/admin/groups/$id"
|
groupRedirect(id, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$id"
|
groupRedirect(id, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Уровни и варианты лабораторной для модалки назначения варианта. */
|
/** Уровни и варианты лабораторной для модалки назначения варианта. */
|
||||||
@@ -490,37 +571,55 @@ class AdminGroupController(
|
|||||||
fun downloadGroupSubmissions(
|
fun downloadGroupSubmissions(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam(required = false) labId: String?,
|
@RequestParam(required = false) labId: String?,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
): ResponseEntity<ByteArray> {
|
): ResponseEntity<ByteArray> {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val group = api.groupDetail(t, id)
|
val teacher = isTeacher(session)
|
||||||
val studentById = group.students.associateBy { it.id }
|
val students = groupStudentsFor(t, id, courseId, teacher)
|
||||||
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
val studentById = students.associateBy { it.id }
|
||||||
val bytes = buildGroupArchive(t, group.name, studentById, labs)
|
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) }
|
||||||
return zipAttachment("solutions-${group.name}.zip", bytes)
|
val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name
|
||||||
|
val bytes = buildGroupArchive(t, groupName, studentById, labs)
|
||||||
|
return zipAttachment("solutions-$groupName.zip", bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Название группы для преподавателя: из состава курса, с откатом на id. */
|
||||||
|
private fun groupNameFor(
|
||||||
|
token: String,
|
||||||
|
groupId: String,
|
||||||
|
courseId: String?,
|
||||||
|
): String {
|
||||||
|
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId
|
||||||
|
return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName }
|
||||||
|
.getOrDefault("")
|
||||||
|
.ifBlank { groupId }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */
|
/** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/groups/{id}/publish")
|
@PostMapping("/groups/{id}/publish")
|
||||||
fun publishGrades(
|
fun publishGrades(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
@RequestParam published: Boolean,
|
@RequestParam published: Boolean,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val all = api.groupDetail(t, id).students.map { it.id }
|
val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
|
||||||
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
|
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
|
||||||
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
|
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
|
||||||
"redirect:/admin/groups/$id"
|
groupRedirect(id, courseId)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$id"
|
groupRedirect(id, courseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
|
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/groups/{id}/publish-student")
|
@PostMapping("/groups/{id}/publish-student")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
fun publishStudent(
|
fun publishStudent(
|
||||||
@@ -528,11 +627,12 @@ class AdminGroupController(
|
|||||||
@RequestParam labId: String,
|
@RequestParam labId: String,
|
||||||
@RequestParam studentId: String,
|
@RequestParam studentId: String,
|
||||||
@RequestParam published: Boolean,
|
@RequestParam published: Boolean,
|
||||||
|
@RequestParam(required = false) courseId: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
): Map<String, Any> =
|
): Map<String, Any> =
|
||||||
try {
|
try {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val memberIds = api.groupDetail(t, id).students.map { it.id }
|
val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
|
||||||
val memberIdSet = memberIds.toSet()
|
val memberIdSet = memberIds.toSet()
|
||||||
val state = api.publicationState(t, id, labId)
|
val state = api.publicationState(t, id, labId)
|
||||||
val current =
|
val current =
|
||||||
|
|||||||
@@ -150,13 +150,27 @@ abstract class AdminSupport(
|
|||||||
protected fun redirectToGroup(
|
protected fun redirectToGroup(
|
||||||
groupId: String?,
|
groupId: String?,
|
||||||
labId: String?,
|
labId: String?,
|
||||||
|
courseId: String? = null,
|
||||||
): String =
|
): String =
|
||||||
when {
|
when {
|
||||||
!groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId"
|
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseId)
|
||||||
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
|
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
|
||||||
else -> "redirect:/admin/groups"
|
else -> "redirect:/admin/groups"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */
|
||||||
|
protected fun groupRedirect(
|
||||||
|
groupId: String,
|
||||||
|
courseId: String?,
|
||||||
|
): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}"
|
||||||
|
|
||||||
|
private fun courseQuery(courseId: String?): String =
|
||||||
|
courseId
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
protected fun buildGroupArchive(
|
protected fun buildGroupArchive(
|
||||||
token: String,
|
token: String,
|
||||||
groupName: String,
|
groupName: String,
|
||||||
|
|||||||
162
web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt
Normal file
162
web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import ru.ysu.lms.web.DateUtils
|
||||||
|
import ru.ysu.lms.web.api.AuditEventDto
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class AuditActionOption(
|
||||||
|
val value: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuditRow(
|
||||||
|
val occurredMsk: String?,
|
||||||
|
val actorLabel: String,
|
||||||
|
val action: String,
|
||||||
|
val actionLabel: String,
|
||||||
|
val entityType: String,
|
||||||
|
val entityId: String?,
|
||||||
|
val entityLabel: String,
|
||||||
|
val courseId: String?,
|
||||||
|
val studentId: String?,
|
||||||
|
val details: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Представление журнала аудита для шаблонов админки. */
|
||||||
|
object AuditViews {
|
||||||
|
private val ACTION_LABELS =
|
||||||
|
linkedMapOf(
|
||||||
|
"GRADE_SET" to "Выставлена оценка",
|
||||||
|
"GRADE_PUBLISH" to "Публикация оценок",
|
||||||
|
"DEFENSE_ASSIGN" to "Назначено задание защиты",
|
||||||
|
"DEFENSE_REMOVE" to "Снято задание защиты",
|
||||||
|
"DEFENSE_CODE_ISSUE" to "Выдан код защиты",
|
||||||
|
"DEFENSE_RESET" to "Сброс защиты",
|
||||||
|
"VARIANT_ASSIGN" to "Назначен вариант",
|
||||||
|
"VARIANT_DISTRIBUTE" to "Распределены варианты",
|
||||||
|
"VARIANT_LEVEL_SET" to "Установлен уровень",
|
||||||
|
"PROFILE_VIEW" to "Просмотр профиля студента",
|
||||||
|
"SUBMISSION_DOWNLOAD" to "Скачивание сдачи",
|
||||||
|
"SUBMISSION_DOWNLOAD_SELF" to "Скачивание своей сдачи",
|
||||||
|
"PROMOTION_DRY_RUN" to "Пробный план повышения",
|
||||||
|
"PROMOTION_APPLY" to "Повышение применено",
|
||||||
|
"PROMOTION_ROLLBACK" to "Повышение откатано",
|
||||||
|
"PROMOTION_CANCEL" to "План повышения отменён",
|
||||||
|
"TEACHER_CREATE" to "Создан преподаватель",
|
||||||
|
"TEACHER_PASSWORD_RESET" to "Сброшен пароль преподавателя",
|
||||||
|
"COURSE_CREATE" to "Создан курс",
|
||||||
|
"COURSE_UPDATE" to "Изменён курс",
|
||||||
|
"COURSE_TEACHER_ASSIGN" to "Назначен преподаватель курса",
|
||||||
|
"COURSE_TEACHER_REMOVE" to "Снят преподаватель курса",
|
||||||
|
"COURSE_GROUP_ATTACH" to "Группа привязана к курсу",
|
||||||
|
"COURSE_GROUP_DETACH" to "Группа отвязана от курса",
|
||||||
|
"COURSE_BOOTSTRAP" to "Bootstrap курса",
|
||||||
|
"CONTENT_IMPORT" to "Импорт материалов",
|
||||||
|
"CONTENT_CHAPTER_CREATE" to "Создана глава",
|
||||||
|
"CONTENT_CHAPTER_RENAME" to "Переименована глава",
|
||||||
|
"CONTENT_CHAPTER_DELETE" to "Удалена глава",
|
||||||
|
"CONTENT_CHAPTER_PUBLISH" to "Публикация главы",
|
||||||
|
"CONTENT_SECTION_CREATE" to "Создан раздел",
|
||||||
|
"CONTENT_SECTION_RENAME" to "Переименован раздел",
|
||||||
|
"CONTENT_SECTION_DELETE" to "Удалён раздел",
|
||||||
|
"CONTENT_SECTION_PUBLISH" to "Публикация раздела",
|
||||||
|
"CONTENT_ARTICLE_CREATE" to "Создана статья",
|
||||||
|
"CONTENT_ARTICLE_UPDATE" to "Изменена статья",
|
||||||
|
"CONTENT_ARTICLE_DELETE" to "Удалена статья",
|
||||||
|
"CONTENT_ARTICLE_MOVE" to "Статья перемещена",
|
||||||
|
"CONTENT_ARTICLE_PUBLISH" to "Публикация статьи",
|
||||||
|
"LAB_CREATE" to "Создана лабораторная",
|
||||||
|
"LAB_IMPORT" to "Импорт лабораторной",
|
||||||
|
"LAB_UPDATE" to "Изменена лабораторная",
|
||||||
|
"LAB_DELETE" to "Удалена лабораторная",
|
||||||
|
"LAB_COPY" to "Скопирована лабораторная",
|
||||||
|
"LAB_PUBLISH" to "Публикация лабораторной",
|
||||||
|
"LAB_LEVEL_ADD" to "Добавлен уровень",
|
||||||
|
"LAB_LEVEL_UPDATE" to "Изменён уровень",
|
||||||
|
"LAB_LEVEL_DELETE" to "Удалён уровень",
|
||||||
|
"LAB_VARIANT_ADD" to "Добавлен вариант",
|
||||||
|
"LAB_VARIANT_UPDATE" to "Изменён вариант",
|
||||||
|
"LAB_VARIANT_DELETE" to "Удалён вариант",
|
||||||
|
"STUDENT_ACADEMIC_LEAVE" to "Академический отпуск",
|
||||||
|
"STUDENT_RETURN" to "Возврат из академического отпуска",
|
||||||
|
"STUDENT_EXPEL" to "Отчисление студента",
|
||||||
|
"STUDENT_RESTORE" to "Восстановление студента",
|
||||||
|
"STUDENT_ARCHIVE" to "Студент в архиве",
|
||||||
|
"STUDENT_DELETE" to "Удалён студент",
|
||||||
|
"GROUP_ARCHIVE" to "Группа в архиве",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ENTITY_LABELS =
|
||||||
|
mapOf(
|
||||||
|
"GRADE" to "Оценка",
|
||||||
|
"GRADE_PUBLICATION" to "Публикация оценок",
|
||||||
|
"STUDENT" to "Студент",
|
||||||
|
"GROUP" to "Группа",
|
||||||
|
"COURSE" to "Курс",
|
||||||
|
"TEACHER" to "Преподаватель",
|
||||||
|
"SUBMISSION" to "Сдача",
|
||||||
|
"DEFENSE" to "Защита",
|
||||||
|
"LAB" to "Лабораторная",
|
||||||
|
"CONTENT" to "Материал",
|
||||||
|
"CONTENT_CHAPTER" to "Глава",
|
||||||
|
"CONTENT_SECTION" to "Раздел",
|
||||||
|
"CONTENT_ARTICLE" to "Статья",
|
||||||
|
"PROMOTION" to "Повышение",
|
||||||
|
"PROMOTION_RUN" to "Запуск повышения",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun rows(events: List<AuditEventDto>): List<AuditRow> =
|
||||||
|
events
|
||||||
|
.sortedByDescending { occurredInstant(it.occurredAt) ?: Instant.MIN }
|
||||||
|
.map(::row)
|
||||||
|
|
||||||
|
fun actionOptions(selected: String?): List<AuditActionOption> {
|
||||||
|
val known = ACTION_LABELS.map { (value, label) -> AuditActionOption(value, label) }
|
||||||
|
val filter = selected?.trim().orEmpty()
|
||||||
|
return if (filter.isNotEmpty() && ACTION_LABELS[filter] == null) {
|
||||||
|
listOf(AuditActionOption(filter, filter)) + known
|
||||||
|
} else {
|
||||||
|
known
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun row(event: AuditEventDto): AuditRow =
|
||||||
|
AuditRow(
|
||||||
|
occurredMsk = DateUtils.formatMsk(event.occurredAt) ?: event.occurredAt.takeIf { it.isNotBlank() },
|
||||||
|
actorLabel =
|
||||||
|
event.actorLogin
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: event.actorAccountId?.takeIf { it.isNotBlank() }
|
||||||
|
?: "система",
|
||||||
|
action = event.action,
|
||||||
|
actionLabel = ACTION_LABELS[event.action] ?: event.action,
|
||||||
|
entityType = event.entityType,
|
||||||
|
entityId = event.entityId,
|
||||||
|
entityLabel = entityLabel(event.entityType, event.entityId),
|
||||||
|
courseId = event.courseId?.takeIf { it.isNotBlank() },
|
||||||
|
studentId = event.studentId?.takeIf { it.isNotBlank() },
|
||||||
|
details = event.details,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun entityLabel(
|
||||||
|
entityType: String,
|
||||||
|
entityId: String?,
|
||||||
|
): String {
|
||||||
|
val type = entityType.trim()
|
||||||
|
val id = entityId?.trim().orEmpty()
|
||||||
|
val typeLabel = ENTITY_LABELS[type] ?: type
|
||||||
|
return when {
|
||||||
|
typeLabel.isNotBlank() && id.isNotBlank() -> "$typeLabel · $id"
|
||||||
|
typeLabel.isNotBlank() -> typeLabel
|
||||||
|
id.isNotBlank() -> id
|
||||||
|
else -> "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun occurredInstant(value: String?): Instant? =
|
||||||
|
try {
|
||||||
|
value?.takeIf { it.isNotBlank() }?.let(Instant::parse)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
24
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt
Normal file
24
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import org.springframework.core.ParameterizedTypeReference
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
|
||||||
|
fun ApiClient.listAudit(
|
||||||
|
token: String,
|
||||||
|
limit: Int = 100,
|
||||||
|
action: String? = null,
|
||||||
|
): List<AuditEventDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<AuditEventDto>>() {}
|
||||||
|
return call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri { builder ->
|
||||||
|
builder.path("/api/v1/audit")
|
||||||
|
builder.queryParam("limit", limit)
|
||||||
|
if (!action.isNullOrBlank()) builder.queryParam("action", action)
|
||||||
|
builder.build()
|
||||||
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.body(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,22 @@ fun ApiClient.courseByNo(
|
|||||||
.body(CourseDetailDto::class.java)
|
.body(CourseDetailDto::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Группы, которые можно привязать к курсу (доступны преподавателю своего курса). */
|
||||||
|
fun ApiClient.courseAvailableGroups(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
): List<CourseGroupOptionDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<CourseGroupOptionDto>>() {}
|
||||||
|
return call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/courses/{id}/available-groups", courseId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.body(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
|
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
|
||||||
fun ApiClient.courseGroupStudents(
|
fun ApiClient.courseGroupStudents(
|
||||||
token: String,
|
token: String,
|
||||||
|
|||||||
200
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt
Normal file
200
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авторский контент в рамках курса: администратор или преподаватель,
|
||||||
|
* назначенный на этот курс. Все операции проверяют принадлежность курсу на стороне
|
||||||
|
* academic-service.
|
||||||
|
*/
|
||||||
|
fun ApiClient.createCourseChapter(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
title: String,
|
||||||
|
slug: String?,
|
||||||
|
): ContentChapterNodeDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/chapters", courseId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("title" to title, "slug" to slug))
|
||||||
|
.retrieve()
|
||||||
|
.body(ContentChapterNodeDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.renameCourseChapter(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
chapterId: String,
|
||||||
|
title: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.put()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("title" to title))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.deleteCourseChapter(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
chapterId: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.delete()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.createCourseSection(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
chapterId: String,
|
||||||
|
title: String,
|
||||||
|
slug: String?,
|
||||||
|
): ContentSectionNodeDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri { builder ->
|
||||||
|
builder
|
||||||
|
.path("/api/v1/courses/{courseId}/content/sections")
|
||||||
|
.queryParam("chapterId", chapterId)
|
||||||
|
.build(courseId)
|
||||||
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("title" to title, "slug" to slug))
|
||||||
|
.retrieve()
|
||||||
|
.body(ContentSectionNodeDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.renameCourseSection(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
sectionId: String,
|
||||||
|
title: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.put()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/sections/{id}", courseId, sectionId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("title" to title))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаление раздела; при непустом [moveTo] статьи переносятся в указанный раздел. */
|
||||||
|
fun ApiClient.deleteCourseSection(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
sectionId: String,
|
||||||
|
moveTo: String? = null,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.delete()
|
||||||
|
.uri { builder ->
|
||||||
|
builder.path("/api/v1/courses/{courseId}/content/sections/{id}")
|
||||||
|
if (!moveTo.isNullOrBlank()) builder.queryParam("moveTo", moveTo)
|
||||||
|
builder.build(courseId, sectionId)
|
||||||
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.createCourseArticle(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
payload: CreateArticlePayload,
|
||||||
|
): ContentArticleDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/articles", courseId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(
|
||||||
|
mapOf(
|
||||||
|
"chapterId" to payload.chapterId,
|
||||||
|
"sectionId" to payload.sectionId,
|
||||||
|
"newChapterTitle" to payload.newChapterTitle,
|
||||||
|
"newSectionTitle" to payload.newSectionTitle,
|
||||||
|
"title" to payload.title,
|
||||||
|
"bodyMd" to payload.bodyMd,
|
||||||
|
),
|
||||||
|
).retrieve()
|
||||||
|
.body(ContentArticleDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.updateCourseArticle(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
articleId: String,
|
||||||
|
title: String,
|
||||||
|
bodyMd: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.put()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/articles/{id}", courseId, articleId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("title" to title, "bodyMd" to bodyMd))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiClient.deleteCourseArticle(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
articleId: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.delete()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/articles/{id}", courseId, articleId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Публикация или скрытие материала курса: [kind] — chapters, sections или articles. */
|
||||||
|
fun ApiClient.setCourseContentPublished(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
published: Boolean,
|
||||||
|
) {
|
||||||
|
require(kind in contentKinds) { "Неизвестный тип материала: $kind" }
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.put()
|
||||||
|
.uri("/api/v1/courses/{courseId}/content/$kind/{id}/published", courseId, id)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("published" to published))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val contentKinds = setOf("chapters", "sections", "articles")
|
||||||
@@ -63,13 +63,19 @@ fun ApiClient.meAssignments(token: String): List<MeAssignmentDto> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ApiClient.listGrades(token: String): List<GradeDto> {
|
fun ApiClient.listGrades(
|
||||||
|
token: String,
|
||||||
|
courseId: String? = null,
|
||||||
|
): List<GradeDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
client
|
client
|
||||||
.get()
|
.get()
|
||||||
.uri("/api/v1/grades")
|
.uri { builder ->
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
builder.path("/api/v1/grades")
|
||||||
|
if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId)
|
||||||
|
builder.build()
|
||||||
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,6 +328,13 @@ data class CourseDetailDto(
|
|||||||
val groups: List<CourseGroupDto> = emptyList(),
|
val groups: List<CourseGroupDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class CourseGroupOptionDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val status: String = "ACTIVE",
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class CourseGroupRosterDto(
|
data class CourseGroupRosterDto(
|
||||||
val groupId: String = "",
|
val groupId: String = "",
|
||||||
@@ -869,3 +876,17 @@ data class StudentGroupPayload(
|
|||||||
val groupId: String,
|
val groupId: String,
|
||||||
val reason: String? = null,
|
val reason: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class AuditEventDto(
|
||||||
|
val id: String = "",
|
||||||
|
val occurredAt: String = "",
|
||||||
|
val actorAccountId: String? = null,
|
||||||
|
val actorLogin: String? = null,
|
||||||
|
val action: String = "",
|
||||||
|
val entityType: String = "",
|
||||||
|
val entityId: String? = null,
|
||||||
|
val courseId: String? = null,
|
||||||
|
val studentId: String? = null,
|
||||||
|
val details: String = "",
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,14 +13,57 @@ class AdminInterceptor : HandlerInterceptor {
|
|||||||
handler: Any,
|
handler: Any,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
|
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
|
||||||
if (user != null && !user.isAdmin && !user.isTeacherLabsAllowed(request.requestURI)) {
|
// ServletPath отбрасывает matrix-параметры (`;x=1`) и не даёт обойти allowlist.
|
||||||
|
val uri = request.servletPath.ifBlank { request.requestURI }
|
||||||
|
if (user != null && !user.isAdmin && !user.isTeacherAllowed(request.method, uri)) {
|
||||||
response.sendRedirect(if (user.isTeacher) "/teacher" else "/student")
|
response.sendRedirect(if (user.isTeacher) "/teacher" else "/student")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Преподавателю доступен редактор лабораторных; остальные разделы /admin — только администратору. */
|
/**
|
||||||
private fun SessionUser.isTeacherLabsAllowed(uri: String): Boolean =
|
* Преподавателю доступны редактор лабораторных и ведомость групп своего курса.
|
||||||
isTeacher && (uri == "/admin/labs" || uri.startsWith("/admin/labs/"))
|
* Всё остальное в /admin — только администратору.
|
||||||
|
*/
|
||||||
|
private fun SessionUser.isTeacherAllowed(
|
||||||
|
method: String,
|
||||||
|
uri: String,
|
||||||
|
): Boolean = isTeacher && (isTeacherLabsAllowed(uri) || isTeacherGradebookAllowed(method, uri))
|
||||||
|
|
||||||
|
/** Преподавателю доступен редактор лабораторных (backend проверяет принадлежность курса). */
|
||||||
|
private fun isTeacherLabsAllowed(uri: String): Boolean = uri == "/admin/labs" || uri.startsWith("/admin/labs/")
|
||||||
|
|
||||||
|
private fun isTeacherGradebookAllowed(
|
||||||
|
method: String,
|
||||||
|
uri: String,
|
||||||
|
): Boolean {
|
||||||
|
val read = method.equals("GET", ignoreCase = true) || method.equals("HEAD", ignoreCase = true)
|
||||||
|
return if (read) {
|
||||||
|
TEACHER_GET_PATHS.any { it.matches(uri) }
|
||||||
|
} else {
|
||||||
|
method.equals("POST", ignoreCase = true) && TEACHER_POST_PATHS.any { it.matches(uri) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** GET/HEAD-страницы ведомости, защит и подсказок комментариев. */
|
||||||
|
val TEACHER_GET_PATHS =
|
||||||
|
listOf(
|
||||||
|
Regex("^/admin/groups/[^/]+$"),
|
||||||
|
Regex("^/admin/groups/[^/]+/assign-modal$"),
|
||||||
|
Regex("^/admin/groups/[^/]+/submissions\\.zip$"),
|
||||||
|
Regex("^/admin/submissions/[^/]+/download$"),
|
||||||
|
Regex("^/admin/defense-watch$"),
|
||||||
|
Regex("^/admin/comment-templates$"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** POST-действия ведомости и защит; создание/импорт групп и аттестации остаются админскими. */
|
||||||
|
val TEACHER_POST_PATHS =
|
||||||
|
listOf(
|
||||||
|
Regex("^/admin/groups/[^/]+/(grades|publish|publish-student|set-level|pin-variant|distribute)$"),
|
||||||
|
Regex("^/admin/defenses/save$"),
|
||||||
|
Regex("^/admin/defenses/[^/]+/(issue-code|reset)$"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import org.springframework.stereotype.Controller
|
|||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
import ru.ysu.lms.web.DateUtils
|
import ru.ysu.lms.web.DateUtils
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
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.courseDetail
|
||||||
import ru.ysu.lms.web.api.courseGroupStudents
|
import ru.ysu.lms.web.api.courseGroupStudents
|
||||||
|
import ru.ysu.lms.web.api.detachCourseGroup
|
||||||
import ru.ysu.lms.web.api.listLabs
|
import ru.ysu.lms.web.api.listLabs
|
||||||
import ru.ysu.lms.web.api.myCourses
|
import ru.ysu.lms.web.api.myCourses
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
@@ -48,8 +56,11 @@ class TeacherController(
|
|||||||
): String {
|
): String {
|
||||||
val token = token(session) ?: return "redirect:/login"
|
val token = token(session) ?: return "redirect:/login"
|
||||||
val course = api.courseDetail(token, id)
|
val course = api.courseDetail(token, id)
|
||||||
|
val groups = course.groups.filter { it.removedAt == null }
|
||||||
model.addAttribute("course", course)
|
model.addAttribute("course", course)
|
||||||
model.addAttribute("groups", course.groups.filter { it.removedAt == null })
|
model.addAttribute("groups", groups)
|
||||||
|
model.addAttribute("groupNames", groupNames(token, id, groups.map { it.groupId }))
|
||||||
|
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"labs",
|
"labs",
|
||||||
api.listLabs(token, id).map { lab ->
|
api.listLabs(token, id).map { lab ->
|
||||||
@@ -67,6 +78,43 @@ class TeacherController(
|
|||||||
return "teacher/course"
|
return "teacher/course"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/courses/{id}/groups/attach")
|
||||||
|
fun attachGroup(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam groupId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
val target = groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
|
||||||
|
api.attachCourseGroup(token, id, CourseGroupPayload(target))
|
||||||
|
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
|
||||||
|
"redirect:/teacher/courses/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/teacher/courses/$id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/courses/{id}/groups/detach")
|
||||||
|
fun detachGroup(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam groupId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.detachCourseGroup(token, id, groupId)
|
||||||
|
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
|
||||||
|
"redirect:/teacher/courses/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/teacher/courses/$id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/courses/{courseId}/groups/{groupId}")
|
@GetMapping("/courses/{courseId}/groups/{groupId}")
|
||||||
fun group(
|
fun group(
|
||||||
@PathVariable courseId: String,
|
@PathVariable courseId: String,
|
||||||
@@ -82,5 +130,18 @@ class TeacherController(
|
|||||||
return "teacher/group"
|
return "teacher/group"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Названия привязанных групп; при недоступности состава показываем id. */
|
||||||
|
private fun groupNames(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
groupIds: List<String>,
|
||||||
|
): Map<String, String> =
|
||||||
|
groupIds.associate { groupId ->
|
||||||
|
groupId to
|
||||||
|
runCatching { api.courseGroupStudents(token, courseId, groupId).groupName }
|
||||||
|
.getOrDefault("")
|
||||||
|
.ifBlank { groupId }
|
||||||
|
}
|
||||||
|
|
||||||
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package ru.ysu.lms.web.teacher
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.CreateArticlePayload
|
||||||
|
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
|
||||||
|
import ru.ysu.lms.web.api.deleteCourseArticle
|
||||||
|
import ru.ysu.lms.web.api.deleteCourseChapter
|
||||||
|
import ru.ysu.lms.web.api.deleteCourseSection
|
||||||
|
import ru.ysu.lms.web.api.renameCourseChapter
|
||||||
|
import ru.ysu.lms.web.api.renameCourseSection
|
||||||
|
import ru.ysu.lms.web.api.setCourseContentPublished
|
||||||
|
import ru.ysu.lms.web.api.updateCourseArticle
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авторские материалы курса для преподавателя. Формы работают без JavaScript:
|
||||||
|
* каждое действие — обычный POST с редиректом назад на страницу курса.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/teacher/courses/{courseId}/content")
|
||||||
|
class TeacherCourseContentController(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val markdown: MarkdownRenderer,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun content(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
model.addAttribute("course", api.courseDetail(token, courseId))
|
||||||
|
model.addAttribute("tree", api.courseContentTree(token, courseId))
|
||||||
|
// Рендерер отдаём в шаблон, чтобы предпросмотр статей всегда совпадал с тем,
|
||||||
|
// что увидит студент (та же санитизация markdown).
|
||||||
|
model.addAttribute("markdown", markdown)
|
||||||
|
"teacher/content"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/teacher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/chapters")
|
||||||
|
fun createChapter(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
@RequestParam(required = false) slug: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.createCourseChapter(token, courseId, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() })
|
||||||
|
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/chapters/{id}/rename")
|
||||||
|
fun renameChapter(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.renameCourseChapter(token, courseId, id, title.trim())
|
||||||
|
redirect.addFlashAttribute("notice", "Глава переименована")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/chapters/{id}/delete")
|
||||||
|
fun deleteChapter(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.deleteCourseChapter(token, courseId, id)
|
||||||
|
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@PostMapping("/sections")
|
||||||
|
fun createSection(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@RequestParam chapterId: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
@RequestParam(required = false) slug: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.createCourseSection(
|
||||||
|
token,
|
||||||
|
courseId,
|
||||||
|
chapterId,
|
||||||
|
title.trim(),
|
||||||
|
slug?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sections/{id}/rename")
|
||||||
|
fun renameSection(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.renameCourseSection(token, courseId, id, title.trim())
|
||||||
|
redirect.addFlashAttribute("notice", "Раздел переименован")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sections/{id}/delete")
|
||||||
|
fun deleteSection(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam(required = false) moveTo: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.deleteCourseSection(token, courseId, id, moveTo)
|
||||||
|
redirect.addFlashAttribute("notice", "Раздел удалён")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@PostMapping("/articles")
|
||||||
|
fun createArticle(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@RequestParam sectionId: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
@RequestParam(defaultValue = "") bodyMd: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.createCourseArticle(
|
||||||
|
token,
|
||||||
|
courseId,
|
||||||
|
CreateArticlePayload(
|
||||||
|
chapterId = null,
|
||||||
|
sectionId = sectionId,
|
||||||
|
newChapterTitle = null,
|
||||||
|
newSectionTitle = null,
|
||||||
|
title = title.trim(),
|
||||||
|
bodyMd = bodyMd,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@GetMapping("/{chapter}/{section}/{article}")
|
||||||
|
fun article(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable chapter: String,
|
||||||
|
@PathVariable section: String,
|
||||||
|
@PathVariable article: String,
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
val dto = api.courseContentArticle(token, courseId, chapter, section, article)
|
||||||
|
model.addAttribute("course", api.courseDetail(token, courseId))
|
||||||
|
model.addAttribute("article", dto)
|
||||||
|
model.addAttribute("bodyHtml", markdown.render(dto.bodyMd))
|
||||||
|
model.addAttribute("back", articlePath(courseId, chapter, section, article))
|
||||||
|
"teacher/content-article"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectContent(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@PostMapping("/articles/{id}/save")
|
||||||
|
fun saveArticle(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam title: String,
|
||||||
|
@RequestParam(defaultValue = "") bodyMd: String,
|
||||||
|
@RequestParam(required = false) back: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.updateCourseArticle(token, courseId, id, title.trim(), bodyMd)
|
||||||
|
redirect.addFlashAttribute("notice", "Статья сохранена")
|
||||||
|
redirectBack(back, contentPath(courseId))
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectBack(back, contentPath(courseId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/articles/{id}/delete")
|
||||||
|
fun deleteArticle(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam(required = false) back: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
return try {
|
||||||
|
api.deleteCourseArticle(token, courseId, id)
|
||||||
|
redirect.addFlashAttribute("notice", "Статья удалена")
|
||||||
|
redirectContent(courseId)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectBack(back, contentPath(courseId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@PostMapping("/{kind}/{id}/published")
|
||||||
|
fun published(
|
||||||
|
@PathVariable courseId: String,
|
||||||
|
@PathVariable kind: String,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam published: Boolean,
|
||||||
|
@RequestParam(required = false) back: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
val token = token(session) ?: return "redirect:/login"
|
||||||
|
if (kind !in setOf("chapters", "sections", "articles")) {
|
||||||
|
redirect.addFlashAttribute("error", "Неизвестный тип материала")
|
||||||
|
return redirectBack(back, contentPath(courseId))
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
api.setCourseContentPublished(token, courseId, kind, id, published)
|
||||||
|
redirect.addFlashAttribute(
|
||||||
|
"notice",
|
||||||
|
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
|
||||||
|
)
|
||||||
|
redirectBack(back, contentPath(courseId))
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectBack(back, contentPath(courseId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentPath(courseId: String): String = "/teacher/courses/$courseId/content"
|
||||||
|
|
||||||
|
private fun articlePath(
|
||||||
|
courseId: String,
|
||||||
|
chapter: String,
|
||||||
|
section: String,
|
||||||
|
article: String,
|
||||||
|
): String = "${contentPath(courseId)}/$chapter/$section/$article"
|
||||||
|
|
||||||
|
private fun redirectContent(courseId: String): String = "redirect:${contentPath(courseId)}"
|
||||||
|
|
||||||
|
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
|
||||||
|
private fun redirectBack(
|
||||||
|
back: String?,
|
||||||
|
fallback: String,
|
||||||
|
): String =
|
||||||
|
if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) {
|
||||||
|
"redirect:$back"
|
||||||
|
} else {
|
||||||
|
"redirect:$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
}
|
||||||
69
web/src/main/resources/templates/admin/audit.html
Normal file
69
web/src/main/resources/templates/admin/audit.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
|
<head><title>Аудит — LMS</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title m-0">Аудит</h1>
|
||||||
|
<p class="meta">Журнал чувствительных действий администраторов и преподавателей.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card card-body mb-6" method="get" th:action="@{/admin/audit}">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="audit-action">Действие</label>
|
||||||
|
<select class="input" id="audit-action" name="action">
|
||||||
|
<option value="">Все действия</option>
|
||||||
|
<option th:each="option : ${actionOptions}"
|
||||||
|
th:value="${option.value}"
|
||||||
|
th:text="${option.label}"
|
||||||
|
th:selected="${option.value == action}">—</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="audit-limit">Записей</label>
|
||||||
|
<input class="input" id="audit-limit" name="limit" type="number" min="1" max="500"
|
||||||
|
th:value="${limit}" aria-describedby="audit-limit-hint">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Показать</button>
|
||||||
|
<a class="btn btn-secondary" th:href="@{/admin/audit}" th:if="${!#strings.isEmpty(action)}">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="audit-limit-hint">Новые записи сверху. Можно показать от 1 до 500 записей.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card card-flush">
|
||||||
|
<table class="tablekit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время (МСК)</th>
|
||||||
|
<th>Кто</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Объект</th>
|
||||||
|
<th>Курс</th>
|
||||||
|
<th>Студент</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="event : ${events}">
|
||||||
|
<td class="text-muted" th:text="${event.occurredMsk} ?: '—'">—</td>
|
||||||
|
<td th:text="${event.actorLabel}">—</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" th:text="${event.actionLabel}" th:title="${event.action}">—</span>
|
||||||
|
</td>
|
||||||
|
<td th:text="${event.entityLabel}" th:title="${event.entityId} ?: ''">—</td>
|
||||||
|
<td class="font-mono text-muted" th:text="${event.courseId} ?: '—'">—</td>
|
||||||
|
<td class="font-mono text-muted" th:text="${event.studentId} ?: '—'">—</td>
|
||||||
|
<td class="text-break" th:text="${event.details} ?: '—'">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(events)}">
|
||||||
|
<td colspan="7" class="empty">Записей аудита нет.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
|
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="content">
|
<div id="content" th:with="teacherMode=${teacherView != null and teacherView}">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker">Ведомость группы</p>
|
<p class="kicker">Ведомость группы</p>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
|
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions" th:unless="${teacherMode}">
|
||||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
|
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
|
||||||
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
|
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
|
||||||
</a>
|
</a>
|
||||||
@@ -45,7 +45,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
<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="@{/admin/groups/{id}/publish-student(id=${group.id})}">
|
<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})}">
|
||||||
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
|
<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="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
|
||||||
<div class="scroll-x">
|
<div class="scroll-x">
|
||||||
@@ -59,13 +60,14 @@
|
|||||||
<span class="lab-col-title" th:text="${lab.title}">—</span>
|
<span class="lab-col-title" th:text="${lab.title}">—</span>
|
||||||
<div class="lab-head-actions">
|
<div class="lab-head-actions">
|
||||||
<button class="btn btn-secondary btn-sm p-2" type="button"
|
<button class="btn btn-secondary btn-sm p-2" type="button"
|
||||||
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
|
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"
|
||||||
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
|
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
|
||||||
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
|
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
|
<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">
|
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||||
<input class="js-pub-published" type="hidden" name="published"
|
<input class="js-pub-published" type="hidden" name="published"
|
||||||
th:value="${all} ? 'false' : 'true'">
|
th:value="${all} ? 'false' : 'true'">
|
||||||
@@ -79,13 +81,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-secondary btn-sm p-2"
|
<a class="btn btn-secondary btn-sm p-2"
|
||||||
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}"
|
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:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
|
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
|
<th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
|
||||||
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
|
<th th:each="att : ${attestations}" th:if="${!teacherMode}" class="text-center attestation-col" data-nosort>
|
||||||
<div class="lab-col-head">
|
<div class="lab-col-head">
|
||||||
<div class="attestation-title">
|
<div class="attestation-title">
|
||||||
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
|
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
|
||||||
@@ -123,11 +125,11 @@
|
|||||||
<tr th:each="row : ${studentGrades}">
|
<tr th:each="row : ${studentGrades}">
|
||||||
<td class="student-cell matrix-sticky">
|
<td class="student-cell matrix-sticky">
|
||||||
<a class="student-name" th:if="${profileCourseId != null}"
|
<a class="student-name" th:if="${profileCourseId != null}"
|
||||||
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
|
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
|
||||||
th:text="${row.fullName}">—</a>
|
th:text="${row.fullName}">—</a>
|
||||||
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
|
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
|
||||||
<div class="student-no-line">
|
<div class="student-no-line">
|
||||||
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
|
<span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
|
||||||
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
||||||
aria-label="Статус пароля"></span>
|
aria-label="Статус пароля"></span>
|
||||||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||||||
@@ -177,7 +179,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="average-cell"
|
<td class="average-cell"
|
||||||
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
|
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
|
||||||
<td th:each="att : ${attestations}" class="attestation-cell">
|
<td th:each="att : ${attestations}" th:if="${!teacherMode}" class="attestation-cell">
|
||||||
<form class="inline-form js-attestation-form" method="post"
|
<form class="inline-form js-attestation-form" method="post"
|
||||||
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
|
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
@@ -195,28 +197,28 @@
|
|||||||
<td class="text-right actions-cell">
|
<td class="text-right actions-cell">
|
||||||
<div class="actions-col">
|
<div class="actions-col">
|
||||||
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
|
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
|
||||||
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
|
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
|
||||||
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
|
<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}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit"
|
<button class="btn btn-secondary btn-sm" type="submit"
|
||||||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form th:if="${row.status == 'ACTIVE'}"
|
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/academic-leave(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/academic-leave(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Оформить академический отпуск? Студент будет переведён в группу следующего набора того же курса.">
|
data-confirm="Оформить академический отпуск? Студент будет переведён в группу следующего набора того же курса.">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit">Академ</button>
|
<button class="btn btn-secondary btn-sm" type="submit">Академ</button>
|
||||||
</form>
|
</form>
|
||||||
<form th:if="${row.status == 'ACTIVE'}"
|
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/archive(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/archive(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Переместить студента в архив? Аккаунт будет заблокирован, зачисление и данные сохранятся.">
|
data-confirm="Переместить студента в архив? Аккаунт будет заблокирован, зачисление и данные сохранятся.">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit">В архив</button>
|
<button class="btn btn-secondary btn-sm" type="submit">В архив</button>
|
||||||
</form>
|
</form>
|
||||||
<form th:if="${row.status == 'ACTIVE'}"
|
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/expel(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/expel(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт.">
|
data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт.">
|
||||||
@@ -224,14 +226,14 @@
|
|||||||
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form th:if="${row.status == 'ACADEMIC_LEAVE'}"
|
<form th:if="${row.status == 'ACADEMIC_LEAVE' and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/return(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/return(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Вернуть студента из академического отпуска в эту группу?">
|
data-confirm="Вернуть студента из академического отпуска в эту группу?">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit">Вернуть в группу</button>
|
<button class="btn btn-secondary btn-sm" type="submit">Вернуть в группу</button>
|
||||||
</form>
|
</form>
|
||||||
<form th:if="${row.status == 'ARCHIVED' or row.status == 'EXPELLED'}"
|
<form th:if="${(row.status == 'ARCHIVED' or row.status == 'EXPELLED') and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/restore(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/restore(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Восстановить студента в этой группе? Аккаунт снова станет активным.">
|
data-confirm="Восстановить студента в этой группе? Аккаунт снова станет активным.">
|
||||||
@@ -239,7 +241,7 @@
|
|||||||
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
|
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form th:if="${row.status == 'ACTIVE'}"
|
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
|
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
|
||||||
@@ -265,6 +267,7 @@
|
|||||||
<div class="dlg-body">
|
<div class="dlg-body">
|
||||||
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
|
<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="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="studentId" id="grade-student">
|
<input type="hidden" name="studentId" id="grade-student">
|
||||||
<input type="hidden" name="labId" id="grade-lab">
|
<input type="hidden" name="labId" id="grade-lab">
|
||||||
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
||||||
@@ -285,7 +288,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Диалог: добавление столбца аттестации -->
|
<!-- Диалог: добавление столбца аттестации -->
|
||||||
<dialog id="modal-add-attestation" class="dlg">
|
<dialog id="modal-add-attestation" class="dlg" th:if="${!teacherMode}">
|
||||||
<div class="dlg-header">
|
<div class="dlg-header">
|
||||||
<h2 class="dlg-title">Новая аттестация</h2>
|
<h2 class="dlg-title">Новая аттестация</h2>
|
||||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||||
@@ -339,6 +342,7 @@
|
|||||||
<form class="inline-form" id="variant-random-form" method="post"
|
<form class="inline-form" id="variant-random-form" method="post"
|
||||||
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
|
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="studentId" id="variant-random-student">
|
<input type="hidden" name="studentId" id="variant-random-student">
|
||||||
<input type="hidden" name="labId" id="variant-random-lab">
|
<input type="hidden" name="labId" id="variant-random-lab">
|
||||||
<input type="hidden" name="labLevelId" id="variant-random-level">
|
<input type="hidden" name="labLevelId" id="variant-random-level">
|
||||||
@@ -347,6 +351,7 @@
|
|||||||
<form class="inline-form" id="variant-pin-form" method="post"
|
<form class="inline-form" id="variant-pin-form" method="post"
|
||||||
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
|
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="studentId" id="variant-pin-student">
|
<input type="hidden" name="studentId" id="variant-pin-student">
|
||||||
<input type="hidden" name="labId" id="variant-pin-lab">
|
<input type="hidden" name="labId" id="variant-pin-lab">
|
||||||
<input type="hidden" name="variantId" id="variant-pin-id">
|
<input type="hidden" name="variantId" id="variant-pin-id">
|
||||||
@@ -364,6 +369,7 @@
|
|||||||
<div class="dlg-body">
|
<div class="dlg-body">
|
||||||
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
|
<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="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||||
<div><label class="label" for="defense-student-select">Студент</label>
|
<div><label class="label" for="defense-student-select">Студент</label>
|
||||||
<select class="input" id="defense-student-select" name="studentId" required>
|
<select class="input" id="defense-student-select" name="studentId" required>
|
||||||
@@ -384,12 +390,14 @@
|
|||||||
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
|
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
|
||||||
<form class="inline-form" id="defense-issue-form" method="post">
|
<form class="inline-form" id="defense-issue-form" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="labId" id="defense-action-lab">
|
<input type="hidden" name="labId" id="defense-action-lab">
|
||||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||||
<button class="btn btn-secondary" type="submit">Выдать код</button>
|
<button class="btn btn-secondary" type="submit">Выдать код</button>
|
||||||
</form>
|
</form>
|
||||||
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
|
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||||
<input type="hidden" name="labId" id="defense-reset-lab">
|
<input type="hidden" name="labId" id="defense-reset-lab">
|
||||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||||
<button class="btn btn-danger" type="submit">Начать заново</button>
|
<button class="btn btn-danger" type="submit">Начать заново</button>
|
||||||
@@ -404,7 +412,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- Диалог добавления студента -->
|
<!-- Диалог добавления студента -->
|
||||||
<dialog id="modal-add-student" class="dlg">
|
<dialog id="modal-add-student" class="dlg" th:if="${!teacherMode}">
|
||||||
<div class="dlg-header">
|
<div class="dlg-header">
|
||||||
<h2 class="dlg-title">Добавить студента</h2>
|
<h2 class="dlg-title">Добавить студента</h2>
|
||||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||||
@@ -448,7 +456,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div th:if="${createdStudent}" class="card import-card">
|
<div th:if="${createdStudent != null and !teacherMode}" class="card import-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="section-title">Студент добавлен</h2>
|
<h2 class="section-title">Студент добавлен</h2>
|
||||||
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
||||||
@@ -458,7 +466,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
|
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
|
||||||
<div th:if="${importResult}" class="card import-card">
|
<div th:if="${importResult != null and !teacherMode}" class="card import-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
Группа <span th:text="${importResult.groupName}">—</span> импортирована
|
Группа <span th:text="${importResult.groupName}">—</span> импортирована
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/promotion}">Повышение</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/promotion}">Повышение</a></li>
|
||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
|
||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
|
||||||
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/audit}">Аудит</a></li>
|
||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
|
||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
|
||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
|
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
|
||||||
|
<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>
|
||||||
|
<span class="nav-sep">/</span>
|
||||||
|
<a th:href="@{/teacher/courses/{courseId}/content(courseId=${course.id})}"
|
||||||
|
class="nav-link">Материалы</a>
|
||||||
|
<span class="nav-sep">/</span>
|
||||||
|
<span th:text="${article.chapterTitle}">Глава</span>
|
||||||
|
<span class="nav-sep">/</span>
|
||||||
|
<span th:text="${article.sectionTitle}">Раздел</span>
|
||||||
|
<span class="nav-sep">/</span>
|
||||||
|
<span th:text="${article.title}">Статья</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page-head mb-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title m-0">
|
||||||
|
<span th:text="${article.title}">Статья</span>
|
||||||
|
<span class="badge"
|
||||||
|
th:classappend="${article.published ? 'badge-ok' : 'badge-warn'}"
|
||||||
|
th:text="${article.published ? 'опубликовано' : 'скрыто от студентов'}">—</span>
|
||||||
|
</h1>
|
||||||
|
<div class="meta mt-2">
|
||||||
|
<span th:text="|${course.title} / ${article.chapterTitle} / ${article.sectionTitle}|">Курс</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<a class="btn btn-sm btn-secondary" th:href="${back}">← К материалам курса</a>
|
||||||
|
<a class="btn btn-sm btn-secondary"
|
||||||
|
th:if="${course.courseNo != null and course.courseNo > 0}"
|
||||||
|
th:href="@{/learn/course/{no}/{chapter}/{section}/{article}(no=${course.courseNo}, chapter=${article.chapterSlug}, section=${article.sectionSlug}, article=${article.slug})}">
|
||||||
|
Студенческий вид
|
||||||
|
</a>
|
||||||
|
<form class="inline-form"
|
||||||
|
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/published(courseId=${course.id}, 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="${back}">
|
||||||
|
<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})}"
|
||||||
|
method="post" data-confirm="Удалить статью?">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="back" th:value="${back}">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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})}"
|
||||||
|
method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="back" th:value="${back}">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="articleTitle">Заголовок</label>
|
||||||
|
<input class="input" id="articleTitle" name="title" th:value="${article.title}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="articleBodyMd">Текст (markdown)</label>
|
||||||
|
<textarea class="input md-editor-body" id="articleBodyMd" name="bodyMd"
|
||||||
|
rows="18" th:text="${article.bodyMd}"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card card-body">
|
||||||
|
<h2 class="section-title m-0">Предпросмотр</h2>
|
||||||
|
<div class="md-body mt-2" th:utext="${bodyHtml}">Предпросмотр статьи</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
186
web/src/main/resources/templates/teacher/content.html
Normal file
186
web/src/main/resources/templates/teacher/content.html
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
|
<head><title th:text="${course.title} + ' — материалы — LMS'">Материалы курса</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<a th:href="@{/teacher/courses/{id}(id=${course.id})}" class="nav-link">← К курсу</a>
|
||||||
|
|
||||||
|
<div class="page-head mb-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title" th:text="|Материалы курса. ${course.title}|">Материалы курса</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span th:text="|Курс №${course.courseNo}|">Курс №1</span>
|
||||||
|
· <span th:text="|Глав: ${#lists.size(tree.chapters)}|">Глав: 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-sm btn-secondary"
|
||||||
|
th:if="${course.courseNo != null and course.courseNo > 0}"
|
||||||
|
th:href="@{/learn/course/{no}(no=${course.courseNo})}">Студенческий вид</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card card-body form-stack mt-4"
|
||||||
|
th:action="@{/teacher/courses/{courseId}/content/chapters(courseId=${course.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<h2 class="section-title m-0">Новая глава</h2>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="chapterTitle">Название главы</label>
|
||||||
|
<input class="input" id="chapterTitle" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="chapterSlug">Слаг (необязательно)</label>
|
||||||
|
<input class="input" id="chapterSlug" name="slug" placeholder="intro">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Создать главу</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div th:if="${tree == null or #lists.isEmpty(tree.chapters)}" class="card card-body text-muted mt-4">
|
||||||
|
В курсе пока нет материалов. Создайте первую главу.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div th:each="chapter : ${tree?.chapters}" class="card card-body chapter-card mt-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="section-title m-0" th:text="${chapter.title}">Глава</h2>
|
||||||
|
<span class="badge"
|
||||||
|
th:classappend="${chapter.published ? 'badge-ok' : 'badge-warn'}"
|
||||||
|
th:text="${chapter.published ? 'опубликовано' : 'скрыто'}">—</span>
|
||||||
|
<span class="text-sm text-muted"
|
||||||
|
th:text="|Разделов: ${#lists.size(chapter.sections)}|">Разделов: 0</span>
|
||||||
|
</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})}"
|
||||||
|
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})}">
|
||||||
|
<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})}"
|
||||||
|
method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input class="input input-sm" name="title" th:value="${chapter.title}" required
|
||||||
|
aria-label="Новое название главы">
|
||||||
|
<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})}"
|
||||||
|
method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted mt-3 mb-0">Разделов пока нет.</p>
|
||||||
|
|
||||||
|
<div th:each="section : ${chapter.sections}" class="mt-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="group-title m-0" th:text="${section.title}">Раздел</h3>
|
||||||
|
<span class="badge"
|
||||||
|
th:classappend="${section.published ? 'badge-ok' : 'badge-warn'}"
|
||||||
|
th:text="${section.published ? 'опубликовано' : 'скрыто'}">—</span>
|
||||||
|
<span class="text-sm text-muted"
|
||||||
|
th:text="|Статей: ${#lists.size(section.articles)}|">Статей: 0</span>
|
||||||
|
</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})}"
|
||||||
|
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})}">
|
||||||
|
<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})}"
|
||||||
|
method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input class="input input-sm" name="title" th:value="${section.title}" required
|
||||||
|
aria-label="Новое название раздела">
|
||||||
|
<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})}"
|
||||||
|
method="post" data-confirm="Удалить раздел со всеми статьями?">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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: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})}"
|
||||||
|
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})}">
|
||||||
|
<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})}"
|
||||||
|
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})}">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form class="card card-body form-stack mt-2"
|
||||||
|
th:action="@{/teacher/courses/{courseId}/content/articles(courseId=${course.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="sectionId" th:value="${section.id}">
|
||||||
|
<div>
|
||||||
|
<label class="label" th:attr="for=|articleTitle-${section.id}|">Заголовок статьи</label>
|
||||||
|
<input class="input" th:id="|articleTitle-${section.id}|" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" th:attr="for=|articleBody-${section.id}|">Текст (markdown)</label>
|
||||||
|
<textarea class="input md-editor-body" th:id="|articleBody-${section.id}|"
|
||||||
|
name="bodyMd" rows="8"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Добавить статью</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card card-body form-stack mt-4"
|
||||||
|
th:action="@{/teacher/courses/{courseId}/content/sections(courseId=${course.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="chapterId" th:value="${chapter.id}">
|
||||||
|
<div>
|
||||||
|
<label class="label" th:attr="for=|sectionTitle-${chapter.id}|">Новый раздел</label>
|
||||||
|
<input class="input" th:id="|sectionTitle-${chapter.id}|" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" th:attr="for=|sectionSlug-${chapter.id}|">Слаг (необязательно)</label>
|
||||||
|
<input class="input" th:id="|sectionSlug-${chapter.id}|" name="slug">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Добавить раздел</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
|
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
|
||||||
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
|
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<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-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
|
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,16 +40,26 @@
|
|||||||
<a th:if="${currentUser.admin}"
|
<a th:if="${currentUser.admin}"
|
||||||
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
|
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
|
||||||
class="font-mono"
|
class="font-mono"
|
||||||
th:text="${group.groupId}">—</a>
|
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
|
||||||
<a th:unless="${currentUser.admin}"
|
<a th:unless="${currentUser.admin}"
|
||||||
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
|
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
|
||||||
class="font-mono"
|
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
|
||||||
th:text="${group.groupId}">—</a>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted" th:text="${group.addedAt}">—</td>
|
<td class="text-muted" th:text="${group.addedAt}">—</td>
|
||||||
<td th:unless="${currentUser.admin}" class="text-center">
|
<td th:unless="${currentUser.admin}" class="text-center">
|
||||||
<a class="btn btn-sm btn-secondary"
|
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||||
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
|
<a class="btn btn-sm btn-secondary"
|
||||||
|
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseId=${course.id})}">Ведомость</a>
|
||||||
|
<a class="btn btn-sm btn-secondary"
|
||||||
|
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
|
||||||
|
<form class="inline-form"
|
||||||
|
th:action="@{/teacher/courses/{courseId}/groups/detach(courseId=${course.id})}"
|
||||||
|
method="post" data-confirm="Отвязать группу от курса? Ведомость и данные группы останутся в системе.">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="groupId" th:value="${group.groupId}">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Отвязать</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(groups)}">
|
<tr th:if="${#lists.isEmpty(groups)}">
|
||||||
@@ -57,7 +68,23 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint mt-2">Состав групп и привязку к курсу изменяет администратор.</p>
|
|
||||||
|
<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">
|
||||||
|
<div><label class="label" for="courseGroup">Привязать группу</label>
|
||||||
|
<select class="input" id="courseGroup" name="groupId" required
|
||||||
|
th:disabled="${#lists.isEmpty(availableGroups)}">
|
||||||
|
<option value="">— выберите группу —</option>
|
||||||
|
<option th:each="group : ${availableGroups}" th:value="${group.id}"
|
||||||
|
th:text="${group.name} + (${group.status == 'ACTIVE'} ? '' : ' (архив)')">—</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint" th:if="${#lists.isEmpty(availableGroups)}">Все доступные группы уже привязаны.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit"
|
||||||
|
th:disabled="${#lists.isEmpty(availableGroups)}">Привязать</button>
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
</form>
|
||||||
|
<p class="hint mt-2" th:if="${currentUser.admin}">Состав групп и привязку к курсу изменяет администратор.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user