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

@@ -168,3 +168,15 @@ data class SubmissionDto(
val createdAt: String,
val files: List<String>,
)
/** Публикация оценок по (группа, лаба): либо всей группе, либо списку студентов. */
data class PublicationState(
val all: Boolean = false,
val studentIds: List<String> = emptyList(),
)
data class PublicationSetRequest(
val groupId: String,
val labId: String,
val studentIds: List<String> = emptyList(),
)

View File

@@ -2,34 +2,45 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.progress.domain.GradePublication
import ru.ysu.lms.progress.domain.GradePublicationStudent
import ru.ysu.lms.progress.repo.GradePublicationRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
import java.util.UUID
@Service
class GradePublicationService(
private val repo: GradePublicationRepository,
private val studentRepo: GradePublicationStudentRepository,
) {
@Transactional
fun setPublished(
/** Текущее состояние публикации по (группа, лаба): группа целиком или список студентов. */
@Transactional(readOnly = true)
fun state(
groupId: UUID,
labId: UUID,
published: Boolean,
): PublicationState {
val all = repo.findByLabIdAndGroupId(labId, groupId) != null
val students = studentRepo.findByGroupIdAndLabId(groupId, labId).map { it.studentId.toString() }
return PublicationState(all, students)
}
/** Устанавливает ровно указанный набор студентов; публикация «всей группе» при этом снимается. */
@Transactional
fun setStudents(
groupId: UUID,
labId: UUID,
studentIds: Collection<UUID>,
) {
val existing = repo.findByLabIdAndGroupId(labId, groupId)
if (published && existing == null) repo.save(GradePublication(groupId, labId))
if (!published && existing != null) repo.delete(existing)
repo.findByLabIdAndGroupId(labId, groupId)?.let { repo.delete(it) }
studentRepo.deleteByGroupIdAndLabId(groupId, labId)
studentIds.distinct().forEach { studentRepo.save(GradePublicationStudent(groupId, labId, it)) }
}
@Transactional(readOnly = true)
fun isPublished(
groupId: UUID,
labId: UUID,
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
@Transactional(readOnly = true)
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
@Transactional(readOnly = true)
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
studentId: UUID,
): Boolean =
repo.findByLabIdAndGroupId(labId, groupId) != null ||
studentRepo.existsByGroupIdAndLabIdAndStudentId(groupId, labId, studentId)
}

View File

@@ -85,28 +85,24 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish")
@GetMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun publishGrades(
fun publicationState(
@RequestParam groupId: java.util.UUID,
@RequestParam labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
@RequestParam published: Boolean,
) {
publications.setPublished(groupId, labId, published)
}
): PublicationState = publications.state(groupId, labId)
@GetMapping("/grades/published")
@PostMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun gradesPublished(
@RequestParam("labId") labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
): Boolean = publications.isPublished(groupId, labId)
@GetMapping("/grades/publications")
@PreAuthorize("isAuthenticated()")
fun groupPublications(
@RequestParam groupId: java.util.UUID,
): List<String> = publications.publishedLabIdsForGroup(groupId)
fun setPublication(
@RequestBody body: PublicationSetRequest,
) {
publications.setStudents(
java.util.UUID.fromString(body.groupId),
java.util.UUID.fromString(body.labId),
body.studentIds.map { java.util.UUID.fromString(it) },
)
}
@GetMapping("/me/grades")
fun myGrades(
@@ -115,7 +111,8 @@ class ProgressController(
): List<GradeResponse> {
val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
val studentId = java.util.UUID.fromString(jwt.subject)
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId), studentId) }
}
@GetMapping("/comment-templates")

View File

@@ -0,0 +1,30 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.io.Serializable
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "grade_publication_students")
@IdClass(GradePublicationStudentKey::class)
class GradePublicationStudent(
@Id @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
@Id @Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
@Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
@Column(name = "published_at", nullable = false) var publishedAt: Instant = Instant.now(),
)
data class GradePublicationStudentKey(
val groupId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
val studentId: UUID = UUID(0, 0),
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -10,8 +10,4 @@ interface GradePublicationRepository : JpaRepository<GradePublication, GradePubl
labId: UUID,
groupId: UUID,
): GradePublication?
fun findByLabId(labId: UUID): List<GradePublication>
fun findByGroupId(groupId: UUID): List<GradePublication>
}

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.GradePublicationStudent
import ru.ysu.lms.progress.domain.GradePublicationStudentKey
import java.util.UUID
interface GradePublicationStudentRepository : JpaRepository<GradePublicationStudent, GradePublicationStudentKey> {
fun findByGroupIdAndLabId(
groupId: UUID,
labId: UUID,
): List<GradePublicationStudent>
fun deleteByGroupIdAndLabId(
groupId: UUID,
labId: UUID,
)
fun existsByGroupIdAndLabIdAndStudentId(
groupId: UUID,
labId: UUID,
studentId: UUID,
): Boolean
}

View File

@@ -0,0 +1,10 @@
-- Per-student grade publication: which students see the grade and comments for a lab.
-- The older group-wide rows in grade_publications keep meaning "visible to everyone in the group".
CREATE TABLE grade_publication_students (
group_id uuid NOT NULL,
lab_id uuid NOT NULL,
student_id uuid NOT NULL,
published_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, lab_id, student_id)
);

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>