Publication per student: checkbox in lab column, partial state in eye, cursor on Variant

This commit is contained in:
2026-10-02 00:33:16 +03:00
parent d5fbb804c3
commit fba4e013e5
14 changed files with 329 additions and 117 deletions

View File

@@ -18,18 +18,21 @@ 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.DefenseDto
import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupPublications
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDefenses
import ru.ysu.lms.web.api.labDetail
@@ -37,8 +40,9 @@ import ru.ysu.lms.web.api.labSubmissions
import ru.ysu.lms.web.api.listGrades
import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.publishGrades
import ru.ysu.lms.web.api.publicationState
import ru.ysu.lms.web.api.setGrade
import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.api.suggestComments
import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder
@@ -141,62 +145,90 @@ class AdminGroupController(
val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
val publishedLabs = java.util.HashSet(api.groupPublications(t, id))
val memberIds = group.students.map { it.id }
val memberIdSet = memberIds.toSet()
val publishedByLab =
labs.associate { lab ->
val state = api.publicationState(t, id, lab.id)
lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet()
}
val labPublication =
labs.associate { lab ->
val effective = publishedByLab.getValue(lab.id)
lab.id to
GroupLabPublication(
allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective },
publishedCount = effective.size,
total = memberIds.size,
)
}
val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } }
val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } }
val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } }
val rows =
group.students.map { s ->
val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }
val cells =
labs.map { lab ->
val va = assignmentsBy.getValue(lab)[s.id]
val sub = submissionsBy.getValue(lab)[s.id]
val def = defensesBy.getValue(lab)[s.id]
GroupLabCell(
labId = lab.id,
labNumber = lab.number,
labTitle = lab.title,
variantNumber = va?.variantNumber,
variantId = va?.variantId,
variantManual = va?.manual == true,
score = gradeBy["${s.id}|${lab.id}"],
published = publishedLabs.contains(lab.id),
submissionSeq = sub?.seq,
submissionId = sub?.id,
defenseStatus = def?.status,
defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd,
)
}
GroupStudentRow(
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
basis = s.basis,
mustChangePassword = s.mustChangePassword,
cells = cells,
variantsSummary =
cells
.filter { it.variantNumber != null }
.joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" },
commentsJson =
mapper.writeValueAsString(
comments.map { cs ->
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
},
),
)
}
val rows = buildRows(group.students, labs, gradeBy, commentsBy, publishedByLab, assignmentsBy, submissionsBy, defensesBy)
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, publishedLabs, submissionsBy)))
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy)))
return "admin/group"
}
@Suppress("LongParameterList")
private fun buildRows(
students: List<StudentRowDto>,
labs: List<LabSummaryDto>,
gradeBy: Map<String, String?>,
commentsBy: Map<String, List<Pair<String, String>>>,
publishedByLab: Map<String, Set<String>>,
assignmentsBy: Map<LabSummaryDto, Map<String, VariantAssignmentResultDto>>,
submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
defensesBy: Map<LabSummaryDto, Map<String, DefenseDto>>,
): List<GroupStudentRow> =
students.map { s ->
val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }
val cells =
labs.map { lab ->
val va = assignmentsBy.getValue(lab)[s.id]
val sub = submissionsBy.getValue(lab)[s.id]
val def = defensesBy.getValue(lab)[s.id]
GroupLabCell(
labId = lab.id,
labNumber = lab.number,
labTitle = lab.title,
variantNumber = va?.variantNumber,
variantId = va?.variantId,
variantManual = va?.manual == true,
score = gradeBy["${s.id}|${lab.id}"],
published = publishedByLab.getValue(lab.id).contains(s.id),
submissionSeq = sub?.seq,
submissionId = sub?.id,
defenseStatus = def?.status,
defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd,
)
}
GroupStudentRow(
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
basis = s.basis,
mustChangePassword = s.mustChangePassword,
cells = cells,
variantsSummary =
cells
.filter { it.variantNumber != null }
.joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" },
commentsJson =
mapper.writeValueAsString(
comments.map { cs ->
cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) }
},
),
)
}
private fun loadGroupAndLabs(
token: String,
id: String,
@@ -206,7 +238,7 @@ class AdminGroupController(
val group: GroupDetailDto,
val labs: List<LabSummaryDto>,
val rows: List<GroupStudentRow>,
val publishedLabs: Set<String>,
val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
)
@@ -224,7 +256,7 @@ class AdminGroupController(
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.publishedLabs, submissionCounts)
return GroupViewModel(data.group, data.labs, candidates, data.rows, data.labPublication, submissionCounts)
}
private data class GroupViewModel(
@@ -232,7 +264,7 @@ class AdminGroupController(
val labs: List<LabSummaryDto>,
val candidates: List<StudentCandidateDto>,
val rows: List<GroupStudentRow>,
val publishedLabs: Set<String>,
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
)
@@ -244,7 +276,7 @@ class AdminGroupController(
model.addAttribute("labs", view.labs)
model.addAttribute("candidates", view.candidates)
model.addAttribute("studentGrades", view.rows)
model.addAttribute("publishedLabIds", view.publishedLabs)
model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts)
}
@@ -331,6 +363,7 @@ class AdminGroupController(
return zipAttachment("solutions-${group.name}.zip", bytes)
}
/** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */
@PostMapping("/groups/{id}/publish")
fun publishGrades(
@PathVariable id: String,
@@ -340,14 +373,41 @@ class AdminGroupController(
redirect: RedirectAttributes,
): String =
try {
api.publishGrades(token(session), labId, id, published)
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы")
val t = token(session)
val all = api.groupDetail(t, id).students.map { it.id }
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
@PostMapping("/groups/{id}/publish-student")
@ResponseBody
fun publishStudent(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam studentId: String,
@RequestParam published: Boolean,
session: HttpSession,
): Map<String, Any> =
try {
val t = token(session)
val memberIds = api.groupDetail(t, id).students.map { it.id }
val memberIdSet = memberIds.toSet()
val state = api.publicationState(t, id, labId)
val current =
(if (state.all) memberIds.toMutableSet() else state.studentIds.filter { it in memberIdSet }.toMutableSet())
if (published) current.add(studentId) else current.remove(studentId)
val target = current.filter { it in memberIdSet }
api.setPublication(t, PublicationSetPayload(id, labId, target))
mapOf("ok" to true, "published" to published, "count" to target.size)
} catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message)
}
@GetMapping("/submissions/{id}/download")
fun downloadSubmissionAsAdmin(
@PathVariable id: String,

View File

@@ -36,6 +36,13 @@ data class AdminStudentGrades(
val commentsJson: String,
)
/** Состояние публикации оценок по лабе (для шапки таблицы группы). */
data class GroupLabPublication(
val allPublished: Boolean = false,
val publishedCount: Int = 0,
val total: Int = 0,
)
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
data class GroupLabCell(
val labId: String = "",

View File

@@ -141,32 +141,31 @@ fun ApiClient.myGrades(
}
}
fun ApiClient.groupPublications(
fun ApiClient.publicationState(
token: String,
groupId: String,
): List<String> {
val type = object : ParameterizedTypeReference<List<String>>() {}
return call {
labId: String,
): PublicationStateDto =
call {
client
.get()
.uri("/api/v1/grades/publications?groupId={gid}", groupId)
.uri("/api/v1/grades/publication?groupId={g}&labId={l}", groupId, labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
.body(PublicationStateDto::class.java)
}
}
fun ApiClient.publishGrades(
fun ApiClient.setPublication(
token: String,
labId: String,
groupId: String,
published: Boolean,
payload: PublicationSetPayload,
) {
call {
client
.post()
.uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published)
.uri("/api/v1/grades/publication")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.toBodilessEntity()
}

View File

@@ -269,6 +269,18 @@ data class GradeSetPayload(
val comments: List<CommentPayload> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PublicationStateDto(
val all: Boolean = false,
val studentIds: List<String> = emptyList(),
)
data class PublicationSetPayload(
val groupId: String,
val labId: String,
val studentIds: List<String> = emptyList(),
)
data class CommentPayload(
val text: String,
val tone: String,

View File

@@ -348,8 +348,10 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.variant-btn {
display: inline-block; padding: 0.18rem 0.5rem; border-radius: 0.4rem;
border: 1px solid var(--c-border); background: var(--c-bg);
font-size: 0.8rem; white-space: nowrap;
font-size: 0.8rem; white-space: nowrap; cursor: pointer; color: var(--c-text);
}
.variant-btn:hover { border-color: var(--c-accent); color: var(--c-accent); }
.pub-toggle { width: 1.05rem; height: 1.05rem; margin: 0; cursor: pointer; accent-color: #16a34a; }
.variant-btn.manual { border-color: var(--c-border-strong, var(--c-border)); font-weight: 600; }
.cell-btns { display: flex; gap: 0.2rem; }
.cell-btn {
@@ -375,6 +377,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.mini-btn:hover { background: var(--c-bg); }
.mini-btn.ok { color: #16a34a; }
.mini-btn.warn { color: #d97706; }
.mini-btn.partial { color: #2563eb; }
/* Увеличенный диалог (оценка + комментарии) */
dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); }

View File

@@ -253,6 +253,47 @@ function removeCommentTemplate(text, done) {
.catch(function () { showToast('Не удалось удалить комментарий.', 'error'); });
}
/* ---------- Публикация оценки и комментариев отдельному студенту ---------- */
function attachPublishToggles() {
var urlEl = $id('pub-url');
if (!urlEl) return;
var csrfEl = document.querySelector('input[name="_csrf"]');
document.querySelectorAll('.pub-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
var desired = cb.checked;
var body = new URLSearchParams();
body.set('_csrf', csrfEl ? csrfEl.value : '');
body.set('labId', cb.getAttribute('data-lab') || '');
body.set('studentId', cb.getAttribute('data-student') || '');
body.set('published', desired ? 'true' : 'false');
cb.disabled = true;
fetch(urlEl.value, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (r) { return r.ok ? r.json() : { ok: false }; })
.then(function (res) {
cb.disabled = false;
if (res && res.ok) {
showToast(desired
? 'Оценка и комментарии опубликованы студенту.'
: 'Оценка и комментарии скрыты от студента.');
} else {
cb.checked = !desired;
showToast((res && res.error) || 'Не удалось изменить публикацию.', 'error');
}
})
.catch(function () {
cb.disabled = false;
cb.checked = !desired;
showToast('Не удалось изменить публикацию.', 'error');
});
});
});
}
/* ---------- Индивидуальное задание (защита) ---------- */
function defenseStatusText(status, code, seconds) {
if (status === 'waiting') {
@@ -418,4 +459,5 @@ document.addEventListener('submit', function (ev) {
window.addEventListener('DOMContentLoaded', function () {
focusLab();
tickDefenseCells();
attachPublishToggles();
});

View File

@@ -29,6 +29,7 @@
</div>
<div th:if="${#lists.isEmpty(labs)}" 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})}">
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x">
<table class="matrix tablekit" id="group-table">
@@ -39,16 +40,18 @@
<div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<div class="lab-head-actions">
<form class="inline-form" th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<form class="inline-form" th:with="pub=${labPublication[lab.id]}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input type="hidden" name="published"
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
th:value="${pub != null and pub.allPublished} ? 'false' : 'true'">
<button class="mini-btn" type="submit"
th:classappend="${publishedLabIds.contains(lab.id)} ? 'ok' : 'warn'"
th:title="${publishedLabIds.contains(lab.id) ? 'Оценки опубликованы — нажмите, чтобы скрыть' : 'Показать оценки и комментарии студентам'}">
<svg th:if="${publishedLabIds.contains(lab.id)}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
<svg th:unless="${publishedLabIds.contains(lab.id)}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg>
th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')"
th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<svg th:if="${pub != null and pub.publishedCount > 0}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
<svg th:unless="${pub != null and pub.publishedCount > 0}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg>
<span class="mini-count" th:if="${pub != null and !pub.allPublished and pub.publishedCount > 0}" th:text="${pub.publishedCount}"></span>
</button>
</form>
<a class="mini-btn"
@@ -70,11 +73,17 @@
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
<div class="cell-stack">
<button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}"
onclick="openVariant(this)"
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button>
<div class="cell-top">
<button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}"
onclick="openVariant(this)"
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button>
<input type="checkbox" class="pub-toggle"
th:checked="${cell.published}"
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
</div>
<div class="cell-btns">
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓<span class="cell-num" th:text="${cell.submissionSeq}"></span></a>