feat(web): ведомость преподавателя, материалы курса и аудит

This commit is contained in:
2026-10-27 20:39:28 +03:00
parent 4d97feaf9e
commit 97e14f040c
20 changed files with 1542 additions and 124 deletions

View File

@@ -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
}
}

View File

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

View File

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

View File

@@ -21,14 +21,19 @@ import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CreateGroupPayload import ru.ysu.lms.web.api.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeDto
import ru.ysu.lms.web.api.GradeSetPayload import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.LabDataDto
import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PublicationSetPayload import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.PublicationStateDto
import ru.ysu.lms.web.api.StudentCandidateDto import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.StudentRowDto import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.SubmissionDto import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.createGroup import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate import ru.ysu.lms.web.api.deleteCommentTemplate
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
@@ -48,6 +53,8 @@ import ru.ysu.lms.web.api.publicationState
import ru.ysu.lms.web.api.setGrade import ru.ysu.lms.web.api.setGrade
import ru.ysu.lms.web.api.setPublication import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.api.suggestComments import ru.ysu.lms.web.api.suggestComments
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@@ -56,6 +63,7 @@ import java.nio.charset.StandardCharsets
@Controller @Controller
@RequestMapping("/admin") @RequestMapping("/admin")
@Suppress("TooManyFunctions")
class AdminGroupController( class AdminGroupController(
api: ApiClient, api: ApiClient,
markdown: MarkdownRenderer, markdown: MarkdownRenderer,
@@ -153,26 +161,95 @@ class AdminGroupController(
model: Model, model: Model,
): String { ): String {
val t = token(session) val t = token(session)
val allLabs = api.listLabs(t) val teacher = isTeacher(session)
val group = api.groupDetail(t, id) val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
val labs = allLabs.filter { it.status == "published" } val view =
val attestations = loadAttestations(t, id) if (teacher) {
val grades = api.listGrades(t) teacherGroupView(t, id, effectiveCourseId)
} else {
adminGroupView(t, id)
}
fillGroupModel(model, view)
model.addAttribute("teacherView", teacher)
model.addAttribute("profileCourseId", effectiveCourseId)
return "admin/group"
}
/** Администратор видит все группы и аттестации; преподаватель — только свой курс. */
private fun adminGroupView(
token: String,
id: String,
): GroupViewModel {
val labs = api.listLabs(token).filter { it.status == "published" }
val group = api.groupDetail(token, id)
val attestations = loadAttestations(token, id)
val grades = api.listGrades(token)
val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates)
}
/**
* Ведомость преподавателя: состав группы берётся из academic в рамках курса,
* чужие лабораторные и оценки отфильтровываются, админские разделы не запрашиваются.
*/
private fun teacherGroupView(
token: String,
id: String,
courseId: String?,
): GroupViewModel {
if (courseId == null) throw ApiException(400, "Не указан курс")
api.courseDetail(token, courseId)
val roster = api.courseGroupStudents(token, courseId, id)
val group =
GroupDetailDto(
id = id,
name = roster.groupName.ifBlank { id },
students =
roster.students.map { student ->
StudentRowDto(
id = student.id,
studentNo = student.studentNo,
fullName = student.fullName,
status = student.status,
)
},
)
val labs = api.listLabs(token).filter { it.courseId == courseId && it.status == "published" }
val labIds = labs.map { it.id }.toSet()
val memberIds = group.students.map { it.id }.toSet()
val grades = api.listGrades(token, courseId).filter { it.studentId in memberIds && it.labId in labIds }
val dataByLab =
if (labs.isEmpty()) {
emptyMap()
} else {
api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
}
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList())
}
@Suppress("LongParameterList")
private fun groupViewModel(
group: GroupDetailDto,
labs: List<LabSummaryDto>,
grades: List<GradeDto>,
dataByLab: Map<String, LabDataDto>,
attestations: List<GroupAttestation>,
candidates: List<StudentCandidateDto>,
): GroupViewModel {
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
val memberIds = group.students.map { it.id } val memberIds = group.students.map { it.id }
val memberIdSet = memberIds.toSet() val memberIdSet = memberIds.toSet()
// Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу
val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId }
val publishedByLab = val publishedByLab =
labs.associate { lab -> labs.associate { lab ->
val state = dataByLab.getValue(lab.id).publication val state = dataByLab[lab.id]?.publication ?: PublicationStateDto()
lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet() lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet()
} }
val labPublication = val labPublication =
labs.associate { lab -> labs.associate { lab ->
val effective = publishedByLab.getValue(lab.id) val effective = publishedByLab[lab.id].orEmpty()
lab.id to lab.id to
GroupLabPublication( GroupLabPublication(
allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective }, allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective },
@@ -180,9 +257,12 @@ class AdminGroupController(
total = memberIds.size, total = memberIds.size,
) )
} }
val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } } val assignmentsBy =
val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } } labs.associateWith { lab -> dataByLab[lab.id]?.assignments.orEmpty().associateBy { it.studentId } }
val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } val submissionsBy =
labs.associateWith { lab -> dataByLab[lab.id]?.submissions.orEmpty().associateBy { it.studentId } }
val defensesBy =
labs.associateWith { lab -> dataByLab[lab.id]?.defenses.orEmpty().associateBy { it.studentId } }
val rows = val rows =
GroupAttestationViews.attach( GroupAttestationViews.attach(
@@ -199,17 +279,19 @@ class AdminGroupController(
), ),
attestations, attestations,
) )
val submissionCounts =
fillGroupModel( labs.associate { lab ->
model, lab.id to (submissionsBy[lab]?.keys?.count { it in memberIdSet } ?: 0)
groupViewModel( }
t, return GroupViewModel(
id, group,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations), labs,
), candidates,
rows,
labPublication,
submissionCounts,
attestations,
) )
model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() })
return "admin/group"
} }
@Suppress("LongParameterList") @Suppress("LongParameterList")
@@ -296,40 +378,6 @@ class AdminGroupController(
.toPlainString() .toPlainString()
} }
private data class GroupRenderData(
val group: GroupDetailDto,
val labs: List<LabSummaryDto>,
val rows: List<GroupStudentRow>,
val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>,
)
private fun groupViewModel(
token: String,
id: String,
data: GroupRenderData,
): GroupViewModel {
val memberIds =
data.group.students
.map { it.id }
.toSet()
val submissionCounts =
data.labs.associate { lab ->
lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
}
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return GroupViewModel(
data.group,
data.labs,
candidates,
data.rows,
data.labPublication,
submissionCounts,
data.attestations,
)
}
private data class GroupViewModel( private data class GroupViewModel(
val group: GroupDetailDto, val group: GroupDetailDto,
val labs: List<LabSummaryDto>, val labs: List<LabSummaryDto>,
@@ -359,11 +407,13 @@ class AdminGroupController(
fun assignModal( fun assignModal(
@PathVariable id: String, @PathVariable id: String,
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam(required = false) courseId: String?,
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
val t = token(session) val t = token(session)
val group = api.groupDetail(t, id) val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher)
val lab = api.labDetail(t, labId) val lab = api.labDetail(t, labId)
val levels = api.getLevels(t, labId).associateBy { it.studentId } val levels = api.getLevels(t, labId).associateBy { it.studentId }
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId } val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
@@ -371,7 +421,7 @@ class AdminGroupController(
val variantUsage = java.util.HashMap<String, Int>() val variantUsage = java.util.HashMap<String, Int>()
assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
val rows = val rows =
group.students.map { s -> students.map { s ->
val assignment = assignments[s.id] val assignment = assignments[s.id]
AssignModalRow( AssignModalRow(
studentId = s.id, studentId = s.id,
@@ -389,10 +439,40 @@ class AdminGroupController(
model.addAttribute("groupId", id) model.addAttribute("groupId", id)
model.addAttribute("rows", rows) model.addAttribute("rows", rows)
model.addAttribute("variantUsage", variantUsage) model.addAttribute("variantUsage", variantUsage)
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId") val courseQuery =
courseId
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "&courseId=$it" }
.orEmpty()
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery")
return "admin/assign-fragment" return "admin/assign-fragment"
} }
private fun isTeacher(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isTeacher == true
/** Студенты группы: для преподавателя — состав курса из academic, для админа — карточка группы. */
private fun groupStudentsFor(
token: String,
groupId: String,
courseId: String?,
teacher: Boolean,
): List<StudentRowDto> {
if (!teacher) return api.groupDetail(token, groupId).students
val effectiveCourseId =
courseId?.trim()?.takeIf { it.isNotBlank() }
?: throw ApiException(400, "Не указан курс")
api.courseDetail(token, effectiveCourseId)
return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student ->
StudentRowDto(
id = student.id,
studentNo = student.studentNo,
fullName = student.fullName,
status = student.status,
)
}
}
private data class AssignModalRow( private data class AssignModalRow(
val studentId: String, val studentId: String,
val fullName: String, val fullName: String,
@@ -409,6 +489,7 @@ class AdminGroupController(
fun setGrade( fun setGrade(
@PathVariable id: String, @PathVariable id: String,
@ModelAttribute form: SetGradeForm, @ModelAttribute form: SetGradeForm,
@RequestParam(required = false) courseId: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
@@ -428,10 +509,10 @@ class AdminGroupController(
), ),
) )
redirect.addFlashAttribute("notice", "Оценка сохранена") redirect.addFlashAttribute("notice", "Оценка сохранена")
"redirect:/admin/groups/$id" groupRedirect(id, courseId)
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id" groupRedirect(id, courseId)
} }
/** Уровни и варианты лабораторной для модалки назначения варианта. */ /** Уровни и варианты лабораторной для модалки назначения варианта. */
@@ -490,37 +571,55 @@ class AdminGroupController(
fun downloadGroupSubmissions( fun downloadGroupSubmissions(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) labId: String?, @RequestParam(required = false) labId: String?,
@RequestParam(required = false) courseId: String?,
session: HttpSession, session: HttpSession,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val t = token(session) val t = token(session)
val group = api.groupDetail(t, id) val teacher = isTeacher(session)
val studentById = group.students.associateBy { it.id } val students = groupStudentsFor(t, id, courseId, teacher)
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } val studentById = students.associateBy { it.id }
val bytes = buildGroupArchive(t, group.name, studentById, labs) val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) }
return zipAttachment("solutions-${group.name}.zip", bytes) val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name
val bytes = buildGroupArchive(t, groupName, studentById, labs)
return zipAttachment("solutions-$groupName.zip", bytes)
}
/** Название группы для преподавателя: из состава курса, с откатом на id. */
private fun groupNameFor(
token: String,
groupId: String,
courseId: String?,
): String {
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName }
.getOrDefault("")
.ifBlank { groupId }
} }
/** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */ /** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */
@Suppress("LongParameterList")
@PostMapping("/groups/{id}/publish") @PostMapping("/groups/{id}/publish")
fun publishGrades( fun publishGrades(
@PathVariable id: String, @PathVariable id: String,
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam published: Boolean, @RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val t = token(session) val t = token(session)
val all = api.groupDetail(t, id).students.map { it.id } val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList())) api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы") redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
"redirect:/admin/groups/$id" groupRedirect(id, courseId)
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id" groupRedirect(id, courseId)
} }
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */ /** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
@Suppress("LongParameterList")
@PostMapping("/groups/{id}/publish-student") @PostMapping("/groups/{id}/publish-student")
@ResponseBody @ResponseBody
fun publishStudent( fun publishStudent(
@@ -528,11 +627,12 @@ class AdminGroupController(
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam studentId: String, @RequestParam studentId: String,
@RequestParam published: Boolean, @RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
session: HttpSession, session: HttpSession,
): Map<String, Any> = ): Map<String, Any> =
try { try {
val t = token(session) val t = token(session)
val memberIds = api.groupDetail(t, id).students.map { it.id } val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
val memberIdSet = memberIds.toSet() val memberIdSet = memberIds.toSet()
val state = api.publicationState(t, id, labId) val state = api.publicationState(t, id, labId)
val current = val current =

View File

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

View 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
}
}

View 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)
}
}

View File

@@ -71,6 +71,22 @@ fun ApiClient.courseByNo(
.body(CourseDetailDto::class.java) .body(CourseDetailDto::class.java)
} }
/** Группы, которые можно привязать к курсу (доступны преподавателю своего курса). */
fun ApiClient.courseAvailableGroups(
token: String,
courseId: String,
): List<CourseGroupOptionDto> {
val type = object : ParameterizedTypeReference<List<CourseGroupOptionDto>>() {}
return call {
client
.get()
.uri("/api/v1/courses/{id}/available-groups", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */ /** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
fun ApiClient.courseGroupStudents( fun ApiClient.courseGroupStudents(
token: String, token: String,

View 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")

View File

@@ -63,13 +63,19 @@ fun ApiClient.meAssignments(token: String): List<MeAssignmentDto> {
} }
} }
fun ApiClient.listGrades(token: String): List<GradeDto> { fun ApiClient.listGrades(
token: String,
courseId: String? = null,
): List<GradeDto> {
val type = object : ParameterizedTypeReference<List<GradeDto>>() {} val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
return call { return call {
client client
.get() .get()
.uri("/api/v1/grades") .uri { builder ->
.header(HttpHeaders.AUTHORIZATION, bearer(token)) builder.path("/api/v1/grades")
if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId)
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve() .retrieve()
.body(type) .body(type)
} }

View File

@@ -328,6 +328,13 @@ data class CourseDetailDto(
val groups: List<CourseGroupDto> = emptyList(), val groups: List<CourseGroupDto> = emptyList(),
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupOptionDto(
val id: String = "",
val name: String = "",
val status: String = "ACTIVE",
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupRosterDto( data class CourseGroupRosterDto(
val groupId: String = "", val groupId: String = "",
@@ -869,3 +876,17 @@ data class StudentGroupPayload(
val groupId: String, val groupId: String,
val reason: String? = null, val reason: String? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class AuditEventDto(
val id: String = "",
val occurredAt: String = "",
val actorAccountId: String? = null,
val actorLogin: String? = null,
val action: String = "",
val entityType: String = "",
val entityId: String? = null,
val courseId: String? = null,
val studentId: String? = null,
val details: String = "",
)

View File

@@ -13,14 +13,57 @@ class AdminInterceptor : HandlerInterceptor {
handler: Any, handler: Any,
): Boolean { ): Boolean {
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
if (user != null && !user.isAdmin && !user.isTeacherLabsAllowed(request.requestURI)) { // ServletPath отбрасывает matrix-параметры (`;x=1`) и не даёт обойти allowlist.
val uri = request.servletPath.ifBlank { request.requestURI }
if (user != null && !user.isAdmin && !user.isTeacherAllowed(request.method, uri)) {
response.sendRedirect(if (user.isTeacher) "/teacher" else "/student") response.sendRedirect(if (user.isTeacher) "/teacher" else "/student")
return false return false
} }
return true return true
} }
/** Преподавателю доступен редактор лабораторных; остальные разделы /admin — только администратору. */ /**
private fun SessionUser.isTeacherLabsAllowed(uri: String): Boolean = * Преподавателю доступны редактор лабораторных и ведомость групп своего курса.
isTeacher && (uri == "/admin/labs" || uri.startsWith("/admin/labs/")) * Всё остальное в /admin — только администратору.
*/
private fun SessionUser.isTeacherAllowed(
method: String,
uri: String,
): Boolean = isTeacher && (isTeacherLabsAllowed(uri) || isTeacherGradebookAllowed(method, uri))
/** Преподавателю доступен редактор лабораторных (backend проверяет принадлежность курса). */
private fun isTeacherLabsAllowed(uri: String): Boolean = uri == "/admin/labs" || uri.startsWith("/admin/labs/")
private fun isTeacherGradebookAllowed(
method: String,
uri: String,
): Boolean {
val read = method.equals("GET", ignoreCase = true) || method.equals("HEAD", ignoreCase = true)
return if (read) {
TEACHER_GET_PATHS.any { it.matches(uri) }
} else {
method.equals("POST", ignoreCase = true) && TEACHER_POST_PATHS.any { it.matches(uri) }
}
}
private companion object {
/** GET/HEAD-страницы ведомости, защит и подсказок комментариев. */
val TEACHER_GET_PATHS =
listOf(
Regex("^/admin/groups/[^/]+$"),
Regex("^/admin/groups/[^/]+/assign-modal$"),
Regex("^/admin/groups/[^/]+/submissions\\.zip$"),
Regex("^/admin/submissions/[^/]+/download$"),
Regex("^/admin/defense-watch$"),
Regex("^/admin/comment-templates$"),
)
/** POST-действия ведомости и защит; создание/импорт групп и аттестации остаются админскими. */
val TEACHER_POST_PATHS =
listOf(
Regex("^/admin/groups/[^/]+/(grades|publish|publish-student|set-level|pin-variant|distribute)$"),
Regex("^/admin/defenses/save$"),
Regex("^/admin/defenses/[^/]+/(issue-code|reset)$"),
)
}
} }

View File

@@ -5,11 +5,19 @@ import org.springframework.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseAvailableGroups
import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseGroupStudents import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.myCourses import ru.ysu.lms.web.api.myCourses
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
@@ -48,8 +56,11 @@ class TeacherController(
): String { ): String {
val token = token(session) ?: return "redirect:/login" val token = token(session) ?: return "redirect:/login"
val course = api.courseDetail(token, id) val course = api.courseDetail(token, id)
val groups = course.groups.filter { it.removedAt == null }
model.addAttribute("course", course) model.addAttribute("course", course)
model.addAttribute("groups", course.groups.filter { it.removedAt == null }) model.addAttribute("groups", groups)
model.addAttribute("groupNames", groupNames(token, id, groups.map { it.groupId }))
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
model.addAttribute( model.addAttribute(
"labs", "labs",
api.listLabs(token, id).map { lab -> api.listLabs(token, id).map { lab ->
@@ -67,6 +78,43 @@ class TeacherController(
return "teacher/course" return "teacher/course"
} }
@PostMapping("/courses/{id}/groups/attach")
fun attachGroup(
@PathVariable id: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
return try {
val target = groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
api.attachCourseGroup(token, id, CourseGroupPayload(target))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/teacher/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
}
}
@PostMapping("/courses/{id}/groups/detach")
fun detachGroup(
@PathVariable id: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
return try {
api.detachCourseGroup(token, id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/teacher/courses/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
}
}
@GetMapping("/courses/{courseId}/groups/{groupId}") @GetMapping("/courses/{courseId}/groups/{groupId}")
fun group( fun group(
@PathVariable courseId: String, @PathVariable courseId: String,
@@ -82,5 +130,18 @@ class TeacherController(
return "teacher/group" return "teacher/group"
} }
/** Названия привязанных групп; при недоступности состава показываем id. */
private fun groupNames(
token: String,
courseId: String,
groupIds: List<String>,
): Map<String, String> =
groupIds.associate { groupId ->
groupId to
runCatching { api.courseGroupStudents(token, courseId, groupId).groupName }
.getOrDefault("")
.ifBlank { groupId }
}
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
} }

View File

@@ -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
}

View 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>

View File

@@ -2,7 +2,7 @@
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}"> <html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head> <head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body> <body>
<div id="content"> <div id="content" th:with="teacherMode=${teacherView != null and teacherView}">
<div class="page-head"> <div class="page-head">
<div> <div>
<p class="kicker">Ведомость группы</p> <p class="kicker">Ведомость группы</p>
@@ -20,7 +20,7 @@
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span> th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
</p> </p>
</div> </div>
<div class="actions"> <div class="actions" th:unless="${teacherMode}">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}"> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли <i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
</a> </a>
@@ -45,7 +45,8 @@
</div> </div>
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div> <div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}"> <input type="hidden" id="pub-url"
th:value="${profileCourseId != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div> <div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x"> <div class="scroll-x">
@@ -59,13 +60,14 @@
<span class="lab-col-title" th:text="${lab.title}">—</span> <span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions"> <div class="lab-head-actions">
<button class="btn btn-secondary btn-sm p-2" type="button" <button class="btn btn-secondary btn-sm p-2" type="button"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign" th:attr="data-url=${profileCourseId != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе"> title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i> <i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button> </button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}" <form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post"> th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input class="js-pub-published" type="hidden" name="published" <input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'"> th:value="${all} ? 'false' : 'true'">
@@ -79,13 +81,13 @@
</button> </button>
</form> </form>
<a class="btn btn-secondary btn-sm p-2" <a class="btn btn-secondary btn-sm p-2"
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}" th:href="${profileCourseId != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a> th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
</div> </div>
</div> </div>
</th> </th>
<th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th> <th class="average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort> <th th:each="att : ${attestations}" th:if="${!teacherMode}" class="text-center attestation-col" data-nosort>
<div class="lab-col-head"> <div class="lab-col-head">
<div class="attestation-title"> <div class="attestation-title">
<span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span> <span class="attestation-kind" th:text="${att.shortLabel}">Пр. атт.</span>
@@ -123,11 +125,11 @@
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky"> <td class="student-cell matrix-sticky">
<a class="student-name" th:if="${profileCourseId != null}" <a class="student-name" th:if="${profileCourseId != null}"
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}" th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
th:text="${row.fullName}">—</a> th:text="${row.fullName}">—</a>
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div> <div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
<div class="student-no-line"> <div class="student-no-line">
<span class="password-dot" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'" <span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'" th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
aria-label="Статус пароля"></span> aria-label="Статус пароля"></span>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div> <div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
@@ -177,7 +179,7 @@
</td> </td>
<td class="average-cell" <td class="average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td> th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="attestation-cell"> <td th:each="att : ${attestations}" th:if="${!teacherMode}" class="attestation-cell">
<form class="inline-form js-attestation-form" method="post" <form class="inline-form js-attestation-form" method="post"
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}"> th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
@@ -195,28 +197,28 @@
<td class="text-right actions-cell"> <td class="text-right actions-cell">
<div class="actions-col"> <div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}" <a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
th:href="@{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a> th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз."> <form th:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit" <button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button> title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form> </form>
<form th:if="${row.status == 'ACTIVE'}" <form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/academic-leave(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/academic-leave(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Оформить академический отпуск? Студент будет переведён в группу следующего набора того же курса."> data-confirm="Оформить академический отпуск? Студент будет переведён в группу следующего набора того же курса.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">Академ</button> <button class="btn btn-secondary btn-sm" type="submit">Академ</button>
</form> </form>
<form th:if="${row.status == 'ACTIVE'}" <form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/archive(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/archive(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Переместить студента в архив? Аккаунт будет заблокирован, зачисление и данные сохранятся."> data-confirm="Переместить студента в архив? Аккаунт будет заблокирован, зачисление и данные сохранятся.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">В архив</button> <button class="btn btn-secondary btn-sm" type="submit">В архив</button>
</form> </form>
<form th:if="${row.status == 'ACTIVE'}" <form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/expel(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/expel(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт."> data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт.">
@@ -224,14 +226,14 @@
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button> <button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form> </form>
<form th:if="${row.status == 'ACADEMIC_LEAVE'}" <form th:if="${row.status == 'ACADEMIC_LEAVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/return(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/return(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Вернуть студента из академического отпуска в эту группу?"> data-confirm="Вернуть студента из академического отпуска в эту группу?">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">Вернуть в группу</button> <button class="btn btn-secondary btn-sm" type="submit">Вернуть в группу</button>
</form> </form>
<form th:if="${row.status == 'ARCHIVED' or row.status == 'EXPELLED'}" <form th:if="${(row.status == 'ARCHIVED' or row.status == 'EXPELLED') and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/restore(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/restore(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Восстановить студента в этой группе? Аккаунт снова станет активным."> data-confirm="Восстановить студента в этой группе? Аккаунт снова станет активным.">
@@ -239,7 +241,7 @@
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button> <button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
</form> </form>
<form th:if="${row.status == 'ACTIVE'}" <form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}" th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить»."> data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
@@ -265,6 +267,7 @@
<div class="dlg-body"> <div class="dlg-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"> <form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="studentId" id="grade-student"> <input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab"> <input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]"> <input type="hidden" name="commentsJson" id="grade-comments" value="[]">
@@ -285,7 +288,7 @@
</dialog> </dialog>
<!-- Диалог: добавление столбца аттестации --> <!-- Диалог: добавление столбца аттестации -->
<dialog id="modal-add-attestation" class="dlg"> <dialog id="modal-add-attestation" class="dlg" th:if="${!teacherMode}">
<div class="dlg-header"> <div class="dlg-header">
<h2 class="dlg-title">Новая аттестация</h2> <h2 class="dlg-title">Новая аттестация</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button> <button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
@@ -339,6 +342,7 @@
<form class="inline-form" id="variant-random-form" method="post" <form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}"> th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="studentId" id="variant-random-student"> <input type="hidden" name="studentId" id="variant-random-student">
<input type="hidden" name="labId" id="variant-random-lab"> <input type="hidden" name="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level"> <input type="hidden" name="labLevelId" id="variant-random-level">
@@ -347,6 +351,7 @@
<form class="inline-form" id="variant-pin-form" method="post" <form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}"> th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="studentId" id="variant-pin-student"> <input type="hidden" name="studentId" id="variant-pin-student">
<input type="hidden" name="labId" id="variant-pin-lab"> <input type="hidden" name="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id"> <input type="hidden" name="variantId" id="variant-pin-id">
@@ -364,6 +369,7 @@
<div class="dlg-body"> <div class="dlg-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post"> <form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="defense-student-select">Студент</label> <div><label class="label" for="defense-student-select">Студент</label>
<select class="input" id="defense-student-select" name="studentId" required> <select class="input" id="defense-student-select" name="studentId" required>
@@ -384,12 +390,14 @@
<div id="defense-actions" class="flex flex-wrap gap-2" hidden> <div id="defense-actions" class="flex flex-wrap gap-2" hidden>
<form class="inline-form" id="defense-issue-form" method="post"> <form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="labId" id="defense-action-lab"> <input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button> <button class="btn btn-secondary" type="submit">Выдать код</button>
</form> </form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?"> <form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="labId" id="defense-reset-lab"> <input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button> <button class="btn btn-danger" type="submit">Начать заново</button>
@@ -404,7 +412,7 @@
<!-- Диалог добавления студента --> <!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="dlg"> <dialog id="modal-add-student" class="dlg" th:if="${!teacherMode}">
<div class="dlg-header"> <div class="dlg-header">
<h2 class="dlg-title">Добавить студента</h2> <h2 class="dlg-title">Добавить студента</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button> <button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
@@ -448,7 +456,7 @@
<div th:if="${createdStudent}" class="card import-card"> <div th:if="${createdStudent != null and !teacherMode}" class="card import-card">
<div class="card-body"> <div class="card-body">
<h2 class="section-title">Студент добавлен</h2> <h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p> <p class="text-sm" th:text="${createdStudent.fullName}">—</p>
@@ -458,7 +466,7 @@
</div> </div>
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. --> <!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
<div th:if="${importResult}" class="card import-card"> <div th:if="${importResult != null and !teacherMode}" class="card import-card">
<div class="card-body"> <div class="card-body">
<h2 class="section-title"> <h2 class="section-title">
Группа <span th:text="${importResult.groupName}">—</span> импортирована Группа <span th:text="${importResult.groupName}">—</span> импортирована

View File

@@ -52,6 +52,7 @@
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/promotion}">Повышение</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/promotion}">Повышение</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/audit}">Аудит</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>

View File

@@ -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>

View 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>

View File

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