diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index bfebb3f..f62e3c7 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -168,3 +168,15 @@ data class SubmissionDto( val createdAt: String, val files: List, ) + +/** Публикация оценок по (группа, лаба): либо всей группе, либо списку студентов. */ +data class PublicationState( + val all: Boolean = false, + val studentIds: List = emptyList(), +) + +data class PublicationSetRequest( + val groupId: String, + val labId: String, + val studentIds: List = emptyList(), +) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt index b5ee4da..fca7d6c 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt @@ -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, ) { - 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 = repo.findByLabId(labId).map { it.groupId.toString() } - - @Transactional(readOnly = true) - fun publishedLabIdsForGroup(groupId: UUID): List = repo.findByGroupId(groupId).map { it.labId.toString() } + studentId: UUID, + ): Boolean = + repo.findByLabIdAndGroupId(labId, groupId) != null || + studentRepo.existsByGroupIdAndLabIdAndStudentId(groupId, labId, studentId) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index 3b46da1..925fe37 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -85,28 +85,24 @@ class ProgressController( @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun listGrades(): List = 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 = 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 { 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") diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublicationStudent.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublicationStudent.kt new file mode 100644 index 0000000..84c08a8 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublicationStudent.kt @@ -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 + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt index 9919e31..ca31009 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt @@ -10,8 +10,4 @@ interface GradePublicationRepository : JpaRepository - - fun findByGroupId(groupId: UUID): List } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt new file mode 100644 index 0000000..8ac633c --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt @@ -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 { + fun findByGroupIdAndLabId( + groupId: UUID, + labId: UUID, + ): List + + fun deleteByGroupIdAndLabId( + groupId: UUID, + labId: UUID, + ) + + fun existsByGroupIdAndLabIdAndStudentId( + groupId: UUID, + labId: UUID, + studentId: UUID, + ): Boolean +} diff --git a/services/progress-service/src/main/resources/db/migration/V8__grade_publication_students.sql b/services/progress-service/src/main/resources/db/migration/V8__grade_publication_students.sql new file mode 100644 index 0000000..41c5004 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V8__grade_publication_students.sql @@ -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) +); diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index fb62664..e3ecad4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -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, + labs: List, + gradeBy: Map, + commentsBy: Map>>, + publishedByLab: Map>, + assignmentsBy: Map>, + submissionsBy: Map>, + defensesBy: Map>, + ): List = + 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, val rows: List, - val publishedLabs: Set, + val labPublication: Map, val submissionsBy: Map>, ) @@ -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, val candidates: List, val rows: List, - val publishedLabs: Set, + val labPublication: Map, val submissionCounts: Map, ) @@ -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 = + 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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 8606943..1708ab6 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -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 = "", diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt index 15eafdb..fbf1bb4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt @@ -141,32 +141,31 @@ fun ApiClient.myGrades( } } -fun ApiClient.groupPublications( +fun ApiClient.publicationState( token: String, groupId: String, -): List { - val type = object : ParameterizedTypeReference>() {} - 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() } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 1046787..dff0783 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -269,6 +269,18 @@ data class GradeSetPayload( val comments: List = emptyList(), ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class PublicationStateDto( + val all: Boolean = false, + val studentIds: List = emptyList(), +) + +data class PublicationSetPayload( + val groupId: String, + val labId: String, + val studentIds: List = emptyList(), +) + data class CommentPayload( val text: String, val tone: String, diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 3f54360..0f0ebf9 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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)); } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 378c17c..3b7c47c 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -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(); }); diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 5e745af..04766c0 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -29,6 +29,7 @@
Опубликованных лабораторных пока нет.
+
@@ -39,16 +40,18 @@
ЛР
-
+ + th:value="${pub != null and pub.allPublished} ? 'false' : 'true'">