Publication per student: checkbox in lab column, partial state in eye, cursor on Variant
This commit is contained in:
@@ -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(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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() }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user