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 createdAt: String,
val files: List<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.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.GradePublicationRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
import java.util.UUID import java.util.UUID
@Service @Service
class GradePublicationService( class GradePublicationService(
private val repo: GradePublicationRepository, private val repo: GradePublicationRepository,
private val studentRepo: GradePublicationStudentRepository,
) { ) {
@Transactional /** Текущее состояние публикации по (группа, лаба): группа целиком или список студентов. */
fun setPublished( @Transactional(readOnly = true)
fun state(
groupId: UUID, groupId: UUID,
labId: 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) repo.findByLabIdAndGroupId(labId, groupId)?.let { repo.delete(it) }
if (published && existing == null) repo.save(GradePublication(groupId, labId)) studentRepo.deleteByGroupIdAndLabId(groupId, labId)
if (!published && existing != null) repo.delete(existing) studentIds.distinct().forEach { studentRepo.save(GradePublicationStudent(groupId, labId, it)) }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isPublished( fun isPublished(
groupId: UUID, groupId: UUID,
labId: UUID, labId: UUID,
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null studentId: UUID,
): Boolean =
@Transactional(readOnly = true) repo.findByLabIdAndGroupId(labId, groupId) != null ||
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() } studentRepo.existsByGroupIdAndLabIdAndStudentId(groupId, labId, studentId)
@Transactional(readOnly = true)
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
} }

View File

@@ -85,28 +85,24 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = grades.listGrades() fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish") @GetMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun publishGrades( fun publicationState(
@RequestParam groupId: java.util.UUID,
@RequestParam labId: java.util.UUID, @RequestParam labId: java.util.UUID,
@RequestParam groupId: java.util.UUID, ): PublicationState = publications.state(groupId, labId)
@RequestParam published: Boolean,
) {
publications.setPublished(groupId, labId, published)
}
@GetMapping("/grades/published") @PostMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun gradesPublished( fun setPublication(
@RequestParam("labId") labId: java.util.UUID, @RequestBody body: PublicationSetRequest,
@RequestParam groupId: java.util.UUID, ) {
): Boolean = publications.isPublished(groupId, labId) publications.setStudents(
java.util.UUID.fromString(body.groupId),
@GetMapping("/grades/publications") java.util.UUID.fromString(body.labId),
@PreAuthorize("isAuthenticated()") body.studentIds.map { java.util.UUID.fromString(it) },
fun groupPublications( )
@RequestParam groupId: java.util.UUID, }
): List<String> = publications.publishedLabIdsForGroup(groupId)
@GetMapping("/me/grades") @GetMapping("/me/grades")
fun myGrades( fun myGrades(
@@ -115,7 +111,8 @@ class ProgressController(
): List<GradeResponse> { ): List<GradeResponse> {
val all = grades.listGrades().filter { it.studentId == jwt.subject } val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList() 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") @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, labId: UUID,
groupId: UUID, groupId: UUID,
): GradePublication? ): 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.markdown.MarkdownRenderer
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.ApiException
import ru.ysu.lms.web.api.DefenseDto
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.LabSummaryDto 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.StudentCandidateDto
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.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
import ru.ysu.lms.web.api.getLabAssignments import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.groupCandidates import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail 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.importGroup
import ru.ysu.lms.web.api.labDefenses import ru.ysu.lms.web.api.labDefenses
import ru.ysu.lms.web.api.labDetail 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.listGrades
import ru.ysu.lms.web.api.listGroups import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs 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.setGrade
import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.api.suggestComments import ru.ysu.lms.web.api.suggestComments
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder import java.net.URLEncoder
@@ -141,13 +145,45 @@ class AdminGroupController(
val grades = api.listGrades(t) val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } 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 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 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 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 defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } }
val rows = val rows = buildRows(group.students, labs, gradeBy, commentsBy, publishedByLab, assignmentsBy, submissionsBy, defensesBy)
group.students.map { s ->
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 comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }
val cells = val cells =
labs.map { lab -> labs.map { lab ->
@@ -162,7 +198,7 @@ class AdminGroupController(
variantId = va?.variantId, variantId = va?.variantId,
variantManual = va?.manual == true, variantManual = va?.manual == true,
score = gradeBy["${s.id}|${lab.id}"], score = gradeBy["${s.id}|${lab.id}"],
published = publishedLabs.contains(lab.id), published = publishedByLab.getValue(lab.id).contains(s.id),
submissionSeq = sub?.seq, submissionSeq = sub?.seq,
submissionId = sub?.id, submissionId = sub?.id,
defenseStatus = def?.status, defenseStatus = def?.status,
@@ -193,10 +229,6 @@ class AdminGroupController(
) )
} }
fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, publishedLabs, submissionsBy)))
return "admin/group"
}
private fun loadGroupAndLabs( private fun loadGroupAndLabs(
token: String, token: String,
id: String, id: String,
@@ -206,7 +238,7 @@ class AdminGroupController(
val group: GroupDetailDto, val group: GroupDetailDto,
val labs: List<LabSummaryDto>, val labs: List<LabSummaryDto>,
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val publishedLabs: Set<String>, val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>, 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) lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0)
} }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) 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( private data class GroupViewModel(
@@ -232,7 +264,7 @@ class AdminGroupController(
val labs: List<LabSummaryDto>, val labs: List<LabSummaryDto>,
val candidates: List<StudentCandidateDto>, val candidates: List<StudentCandidateDto>,
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val publishedLabs: Set<String>, val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>, val submissionCounts: Map<String, Int>,
) )
@@ -244,7 +276,7 @@ class AdminGroupController(
model.addAttribute("labs", view.labs) model.addAttribute("labs", view.labs)
model.addAttribute("candidates", view.candidates) model.addAttribute("candidates", view.candidates)
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("publishedLabIds", view.publishedLabs) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
} }
@@ -331,6 +363,7 @@ class AdminGroupController(
return zipAttachment("solutions-${group.name}.zip", bytes) return zipAttachment("solutions-${group.name}.zip", bytes)
} }
/** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */
@PostMapping("/groups/{id}/publish") @PostMapping("/groups/{id}/publish")
fun publishGrades( fun publishGrades(
@PathVariable id: String, @PathVariable id: String,
@@ -340,14 +373,41 @@ class AdminGroupController(
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
api.publishGrades(token(session), labId, id, published) val t = token(session)
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы") 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" "redirect:/admin/groups/$id"
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id" "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") @GetMapping("/submissions/{id}/download")
fun downloadSubmissionAsAdmin( fun downloadSubmissionAsAdmin(
@PathVariable id: String, @PathVariable id: String,

View File

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

View File

@@ -141,32 +141,31 @@ fun ApiClient.myGrades(
} }
} }
fun ApiClient.groupPublications( fun ApiClient.publicationState(
token: String, token: String,
groupId: String, groupId: String,
): List<String> { labId: String,
val type = object : ParameterizedTypeReference<List<String>>() {} ): PublicationStateDto =
return call { call {
client client
.get() .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)) .header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve() .retrieve()
.body(type) .body(PublicationStateDto::class.java)
}
} }
fun ApiClient.publishGrades( fun ApiClient.setPublication(
token: String, token: String,
labId: String, payload: PublicationSetPayload,
groupId: String,
published: Boolean,
) { ) {
call { call {
client client
.post() .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)) .header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve() .retrieve()
.toBodilessEntity() .toBodilessEntity()
} }

View File

@@ -269,6 +269,18 @@ data class GradeSetPayload(
val comments: List<CommentPayload> = emptyList(), 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( data class CommentPayload(
val text: String, val text: String,
val tone: 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 { .variant-btn {
display: inline-block; padding: 0.18rem 0.5rem; border-radius: 0.4rem; display: inline-block; padding: 0.18rem 0.5rem; border-radius: 0.4rem;
border: 1px solid var(--c-border); background: var(--c-bg); 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; } .variant-btn.manual { border-color: var(--c-border-strong, var(--c-border)); font-weight: 600; }
.cell-btns { display: flex; gap: 0.2rem; } .cell-btns { display: flex; gap: 0.2rem; }
.cell-btn { .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:hover { background: var(--c-bg); }
.mini-btn.ok { color: #16a34a; } .mini-btn.ok { color: #16a34a; }
.mini-btn.warn { color: #d97706; } .mini-btn.warn { color: #d97706; }
.mini-btn.partial { color: #2563eb; }
/* Увеличенный диалог (оценка + комментарии) */ /* Увеличенный диалог (оценка + комментарии) */
dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); } dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); }

View File

@@ -253,6 +253,47 @@ function removeCommentTemplate(text, done) {
.catch(function () { showToast('Не удалось удалить комментарий.', 'error'); }); .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) { function defenseStatusText(status, code, seconds) {
if (status === 'waiting') { if (status === 'waiting') {
@@ -418,4 +459,5 @@ document.addEventListener('submit', function (ev) {
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
focusLab(); focusLab();
tickDefenseCells(); tickDefenseCells();
attachPublishToggles();
}); });

View File

@@ -29,6 +29,7 @@
</div> </div>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</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="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x"> <div class="scroll-x">
<table class="matrix tablekit" id="group-table"> <table class="matrix tablekit" id="group-table">
@@ -39,16 +40,18 @@
<div class="lab-col-head"> <div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a> <a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<div class="lab-head-actions"> <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="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input type="hidden" name="published" <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" <button class="mini-btn" type="submit"
th:classappend="${publishedLabIds.contains(lab.id)} ? 'ok' : 'warn'" th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')"
th:title="${publishedLabIds.contains(lab.id) ? 'Оценки опубликованы — нажмите, чтобы скрыть' : 'Показать оценки и комментарии студентам'}"> th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<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: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="${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> <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> </button>
</form> </form>
<a class="mini-btn" <a class="mini-btn"
@@ -70,11 +73,17 @@
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab" <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 : 'без варианта'}"> th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
<div class="cell-stack"> <div class="cell-stack">
<div class="cell-top">
<button class="variant-btn" type="button" <button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''" 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}" 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)" onclick="openVariant(this)"
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button> 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"> <div class="cell-btns">
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение" <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> 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>