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(
|
||||
@PathVariable labId: String,
|
||||
@ModelAttribute form: DefenseForm,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||
redirectToGroup(form.groupId, labId)
|
||||
redirectToGroup(form.groupId, labId, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(form.groupId, labId)
|
||||
redirectToGroup(form.groupId, labId, courseId)
|
||||
}
|
||||
|
||||
/** Назначение/правка индивидуального задания со страницы группы. */
|
||||
@@ -79,16 +80,17 @@ class AdminDefenseController(
|
||||
fun saveDefense(
|
||||
@ModelAttribute form: DefenseForm,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
|
||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||
redirectToGroup(form.groupId, labId)
|
||||
redirectToGroup(form.groupId, labId, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(form.groupId, labId)
|
||||
redirectToGroup(form.groupId, labId, courseId)
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{labId}/defense/import")
|
||||
@@ -111,37 +113,41 @@ class AdminDefenseController(
|
||||
redirect: RedirectAttributes,
|
||||
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@PostMapping("/defenses/{id}/issue-code")
|
||||
fun issueDefenseCode(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
val code = api.issueDefenseCode(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
|
||||
redirectToGroup(groupId, labId)
|
||||
redirectToGroup(groupId, labId, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(groupId, labId)
|
||||
redirectToGroup(groupId, labId, courseId)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@PostMapping("/defenses/{id}/reset")
|
||||
fun resetDefenseSession(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.resetDefense(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
|
||||
redirectToGroup(groupId, labId)
|
||||
redirectToGroup(groupId, labId, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(groupId, labId)
|
||||
redirectToGroup(groupId, labId, courseId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,39 +31,43 @@ class AdminDistributeController(
|
||||
fun setStudentLevel(
|
||||
@PathVariable gid: String,
|
||||
@ModelAttribute form: StudentLevelForm,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
|
||||
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{gid}/pin-variant")
|
||||
fun pinStudentVariant(
|
||||
@PathVariable gid: String,
|
||||
@ModelAttribute form: PinVariantForm,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
|
||||
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@PostMapping("/groups/{gid}/distribute")
|
||||
fun distributeForGroup(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
@@ -74,10 +78,10 @@ class AdminDistributeController(
|
||||
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
}
|
||||
|
||||
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
|
||||
@@ -85,6 +89,7 @@ class AdminDistributeController(
|
||||
fun distributeAllForGroup(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
@@ -93,7 +98,7 @@ class AdminDistributeController(
|
||||
val labs = api.listLabs(t).filter { it.status == "published" }
|
||||
if (labs.isEmpty()) {
|
||||
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
|
||||
return "redirect:/admin/groups/$gid"
|
||||
return groupRedirect(gid, courseId)
|
||||
}
|
||||
val result =
|
||||
api.distribute(
|
||||
@@ -101,10 +106,10 @@ class AdminDistributeController(
|
||||
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
groupRedirect(gid, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
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.CreateGroupPayload
|
||||
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.GroupDetailDto
|
||||
import ru.ysu.lms.web.api.LabDataDto
|
||||
import ru.ysu.lms.web.api.LabSummaryDto
|
||||
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.StudentRowDto
|
||||
import ru.ysu.lms.web.api.SubmissionDto
|
||||
import ru.ysu.lms.web.api.VariantAssignmentResultDto
|
||||
import ru.ysu.lms.web.api.courseDetail
|
||||
import ru.ysu.lms.web.api.courseGroupStudents
|
||||
import ru.ysu.lms.web.api.createGroup
|
||||
import ru.ysu.lms.web.api.deleteCommentTemplate
|
||||
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.setPublication
|
||||
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 java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
@@ -56,6 +63,7 @@ import java.nio.charset.StandardCharsets
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
@Suppress("TooManyFunctions")
|
||||
class AdminGroupController(
|
||||
api: ApiClient,
|
||||
markdown: MarkdownRenderer,
|
||||
@@ -153,26 +161,95 @@ class AdminGroupController(
|
||||
model: Model,
|
||||
): String {
|
||||
val t = token(session)
|
||||
val allLabs = api.listLabs(t)
|
||||
val group = api.groupDetail(t, id)
|
||||
val labs = allLabs.filter { it.status == "published" }
|
||||
val attestations = loadAttestations(t, id)
|
||||
val grades = api.listGrades(t)
|
||||
val teacher = isTeacher(session)
|
||||
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
|
||||
val view =
|
||||
if (teacher) {
|
||||
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 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 memberIds = group.students.map { it.id }
|
||||
val memberIdSet = memberIds.toSet()
|
||||
// Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу
|
||||
val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId }
|
||||
val publishedByLab =
|
||||
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()
|
||||
}
|
||||
val labPublication =
|
||||
labs.associate { lab ->
|
||||
val effective = publishedByLab.getValue(lab.id)
|
||||
val effective = publishedByLab[lab.id].orEmpty()
|
||||
lab.id to
|
||||
GroupLabPublication(
|
||||
allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective },
|
||||
@@ -180,9 +257,12 @@ class AdminGroupController(
|
||||
total = memberIds.size,
|
||||
)
|
||||
}
|
||||
val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } }
|
||||
val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } }
|
||||
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
|
||||
val assignmentsBy =
|
||||
labs.associateWith { lab -> dataByLab[lab.id]?.assignments.orEmpty().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 =
|
||||
GroupAttestationViews.attach(
|
||||
@@ -199,17 +279,19 @@ class AdminGroupController(
|
||||
),
|
||||
attestations,
|
||||
)
|
||||
|
||||
fillGroupModel(
|
||||
model,
|
||||
groupViewModel(
|
||||
t,
|
||||
id,
|
||||
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
|
||||
),
|
||||
val submissionCounts =
|
||||
labs.associate { lab ->
|
||||
lab.id to (submissionsBy[lab]?.keys?.count { it in memberIdSet } ?: 0)
|
||||
}
|
||||
return GroupViewModel(
|
||||
group,
|
||||
labs,
|
||||
candidates,
|
||||
rows,
|
||||
labPublication,
|
||||
submissionCounts,
|
||||
attestations,
|
||||
)
|
||||
model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() })
|
||||
return "admin/group"
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@@ -296,40 +378,6 @@ class AdminGroupController(
|
||||
.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(
|
||||
val group: GroupDetailDto,
|
||||
val labs: List<LabSummaryDto>,
|
||||
@@ -359,11 +407,13 @@ class AdminGroupController(
|
||||
fun assignModal(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
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 levels = api.getLevels(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>()
|
||||
assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
|
||||
val rows =
|
||||
group.students.map { s ->
|
||||
students.map { s ->
|
||||
val assignment = assignments[s.id]
|
||||
AssignModalRow(
|
||||
studentId = s.id,
|
||||
@@ -389,10 +439,40 @@ class AdminGroupController(
|
||||
model.addAttribute("groupId", id)
|
||||
model.addAttribute("rows", rows)
|
||||
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"
|
||||
}
|
||||
|
||||
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(
|
||||
val studentId: String,
|
||||
val fullName: String,
|
||||
@@ -409,6 +489,7 @@ class AdminGroupController(
|
||||
fun setGrade(
|
||||
@PathVariable id: String,
|
||||
@ModelAttribute form: SetGradeForm,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
@@ -428,10 +509,10 @@ class AdminGroupController(
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Оценка сохранена")
|
||||
"redirect:/admin/groups/$id"
|
||||
groupRedirect(id, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
groupRedirect(id, courseId)
|
||||
}
|
||||
|
||||
/** Уровни и варианты лабораторной для модалки назначения варианта. */
|
||||
@@ -490,37 +571,55 @@ class AdminGroupController(
|
||||
fun downloadGroupSubmissions(
|
||||
@PathVariable id: String,
|
||||
@RequestParam(required = false) labId: String?,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val t = token(session)
|
||||
val group = api.groupDetail(t, id)
|
||||
val studentById = group.students.associateBy { it.id }
|
||||
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
||||
val bytes = buildGroupArchive(t, group.name, studentById, labs)
|
||||
return zipAttachment("solutions-${group.name}.zip", bytes)
|
||||
val teacher = isTeacher(session)
|
||||
val students = groupStudentsFor(t, id, courseId, teacher)
|
||||
val studentById = students.associateBy { it.id }
|
||||
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) }
|
||||
val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name
|
||||
val 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")
|
||||
fun publishGrades(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam published: Boolean,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
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()))
|
||||
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
|
||||
"redirect:/admin/groups/$id"
|
||||
groupRedirect(id, courseId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
groupRedirect(id, courseId)
|
||||
}
|
||||
|
||||
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
|
||||
@Suppress("LongParameterList")
|
||||
@PostMapping("/groups/{id}/publish-student")
|
||||
@ResponseBody
|
||||
fun publishStudent(
|
||||
@@ -528,11 +627,12 @@ class AdminGroupController(
|
||||
@RequestParam labId: String,
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam published: Boolean,
|
||||
@RequestParam(required = false) courseId: String?,
|
||||
session: HttpSession,
|
||||
): Map<String, Any> =
|
||||
try {
|
||||
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 state = api.publicationState(t, id, labId)
|
||||
val current =
|
||||
|
||||
@@ -150,13 +150,27 @@ abstract class AdminSupport(
|
||||
protected fun redirectToGroup(
|
||||
groupId: String?,
|
||||
labId: String?,
|
||||
courseId: String? = null,
|
||||
): String =
|
||||
when {
|
||||
!groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId"
|
||||
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseId)
|
||||
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
|
||||
else -> "redirect:/admin/groups"
|
||||
}
|
||||
|
||||
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */
|
||||
protected fun groupRedirect(
|
||||
groupId: String,
|
||||
courseId: String?,
|
||||
): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}"
|
||||
|
||||
private fun courseQuery(courseId: String?): String =
|
||||
courseId
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
|
||||
.orEmpty()
|
||||
|
||||
protected fun buildGroupArchive(
|
||||
token: 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)
|
||||
}
|
||||
|
||||
/** Группы, которые можно привязать к курсу (доступны преподавателю своего курса). */
|
||||
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(
|
||||
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>>() {}
|
||||
return call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/grades")
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.uri { builder ->
|
||||
builder.path("/api/v1/grades")
|
||||
if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId)
|
||||
builder.build()
|
||||
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(type)
|
||||
}
|
||||
|
||||
@@ -328,6 +328,13 @@ data class CourseDetailDto(
|
||||
val groups: List<CourseGroupDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CourseGroupOptionDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val status: String = "ACTIVE",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CourseGroupRosterDto(
|
||||
val groupId: String = "",
|
||||
@@ -869,3 +876,17 @@ data class StudentGroupPayload(
|
||||
val groupId: String,
|
||||
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,
|
||||
): Boolean {
|
||||
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")
|
||||
return false
|
||||
}
|
||||
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.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.web.DateUtils
|
||||
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.courseGroupStudents
|
||||
import ru.ysu.lms.web.api.detachCourseGroup
|
||||
import ru.ysu.lms.web.api.listLabs
|
||||
import ru.ysu.lms.web.api.myCourses
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
@@ -48,8 +56,11 @@ class TeacherController(
|
||||
): String {
|
||||
val token = token(session) ?: return "redirect:/login"
|
||||
val course = api.courseDetail(token, id)
|
||||
val groups = course.groups.filter { it.removedAt == null }
|
||||
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(
|
||||
"labs",
|
||||
api.listLabs(token, id).map { lab ->
|
||||
@@ -67,6 +78,43 @@ class TeacherController(
|
||||
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}")
|
||||
fun group(
|
||||
@PathVariable courseId: String,
|
||||
@@ -82,5 +130,18 @@ class TeacherController(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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})}">
|
||||
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<div id="content" th:with="teacherMode=${teacherView != null and teacherView}">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<p class="kicker">Ведомость группы</p>
|
||||
@@ -20,7 +20,7 @@
|
||||
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
|
||||
</p>
|
||||
</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')}">
|
||||
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
|
||||
</a>
|
||||
@@ -45,7 +45,8 @@
|
||||
</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 class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
|
||||
<div class="scroll-x">
|
||||
@@ -59,13 +60,14 @@
|
||||
<span class="lab-col-title" th:text="${lab.title}">—</span>
|
||||
<div class="lab-head-actions">
|
||||
<button class="btn btn-secondary btn-sm p-2" type="button"
|
||||
th:attr="data-url=@{/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="Выдача вариантов: уровни, варианты, автокраспределение по группе">
|
||||
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
|
||||
</button>
|
||||
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
|
||||
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<input class="js-pub-published" type="hidden" name="published"
|
||||
th:value="${all} ? 'false' : 'true'">
|
||||
@@ -79,13 +81,13 @@
|
||||
</button>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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="attestation-title">
|
||||
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
|
||||
@@ -123,11 +125,11 @@
|
||||
<tr th:each="row : ${studentGrades}">
|
||||
<td class="student-cell matrix-sticky">
|
||||
<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>
|
||||
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
|
||||
<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} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
|
||||
aria-label="Статус пароля"></span>
|
||||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||||
@@ -177,7 +179,7 @@
|
||||
</td>
|
||||
<td class="average-cell"
|
||||
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"
|
||||
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
@@ -195,28 +197,28 @@
|
||||
<td class="text-right actions-cell">
|
||||
<div class="actions-col">
|
||||
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
|
||||
th:href="@{/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="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
|
||||
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:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"
|
||||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Оформить академический отпуск? Студент будет переведён в группу следующего набора того же курса.">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Академ</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Переместить студента в архив? Аккаунт будет заблокирован, зачисление и данные сохранятся.">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">В архив</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт.">
|
||||
@@ -224,14 +226,14 @@
|
||||
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Вернуть студента из академического отпуска в эту группу?">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Вернуть в группу</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Восстановить студента в этой группе? Аккаунт снова станет активным.">
|
||||
@@ -239,7 +241,7 @@
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
|
||||
</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})}"
|
||||
method="post"
|
||||
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
|
||||
@@ -265,6 +267,7 @@
|
||||
<div class="dlg-body">
|
||||
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="studentId" id="grade-student">
|
||||
<input type="hidden" name="labId" id="grade-lab">
|
||||
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
||||
@@ -285,7 +288,7 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог: добавление столбца аттестации -->
|
||||
<dialog id="modal-add-attestation" class="dlg">
|
||||
<dialog id="modal-add-attestation" class="dlg" th:if="${!teacherMode}">
|
||||
<div class="dlg-header">
|
||||
<h2 class="dlg-title">Новая аттестация</h2>
|
||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||
@@ -339,6 +342,7 @@
|
||||
<form class="inline-form" id="variant-random-form" method="post"
|
||||
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="studentId" id="variant-random-student">
|
||||
<input type="hidden" name="labId" id="variant-random-lab">
|
||||
<input type="hidden" name="labLevelId" id="variant-random-level">
|
||||
@@ -347,6 +351,7 @@
|
||||
<form class="inline-form" id="variant-pin-form" method="post"
|
||||
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="studentId" id="variant-pin-student">
|
||||
<input type="hidden" name="labId" id="variant-pin-lab">
|
||||
<input type="hidden" name="variantId" id="variant-pin-id">
|
||||
@@ -364,6 +369,7 @@
|
||||
<div class="dlg-body">
|
||||
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<div><label class="label" for="defense-student-select">Студент</label>
|
||||
<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>
|
||||
<form class="inline-form" id="defense-issue-form" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="labId" id="defense-action-lab">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<button class="btn btn-secondary" type="submit">Выдать код</button>
|
||||
</form>
|
||||
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
|
||||
<input type="hidden" name="labId" id="defense-reset-lab">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<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">
|
||||
<h2 class="dlg-title">Добавить студента</h2>
|
||||
<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">
|
||||
<h2 class="section-title">Студент добавлен</h2>
|
||||
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
||||
@@ -458,7 +466,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
|
||||
<div th:if="${importResult}" class="card import-card">
|
||||
<div th:if="${importResult != null and !teacherMode}" class="card import-card">
|
||||
<div class="card-body">
|
||||
<h2 class="section-title">
|
||||
Группа <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/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/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/labs}">Лабораторные</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(labs)}|">Лабораторных: 0</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,16 +40,26 @@
|
||||
<a th:if="${currentUser.admin}"
|
||||
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
|
||||
class="font-mono"
|
||||
th:text="${group.groupId}">—</a>
|
||||
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
|
||||
<a th:unless="${currentUser.admin}"
|
||||
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
|
||||
class="font-mono"
|
||||
th:text="${group.groupId}">—</a>
|
||||
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
|
||||
</td>
|
||||
<td class="text-muted" th:text="${group.addedAt}">—</td>
|
||||
<td th:unless="${currentUser.admin}" class="text-center">
|
||||
<a class="btn btn-sm btn-secondary"
|
||||
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<a class="btn btn-sm btn-secondary"
|
||||
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseId=${course.id})}">Ведомость</a>
|
||||
<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>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(groups)}">
|
||||
@@ -57,7 +68,23 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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 class="col-lg-6">
|
||||
|
||||
Reference in New Issue
Block a user