Lab editing and group grades

- academic-service: PUT /labs/{id} (title, deadline, defense, points, status,
  description), POST/PUT/DELETE variants endpoints
- progress-service: lab_grades table, POST/GET /api/v1/grades, GET /me/grades
- gateway: routes for /api/v1/assign and /api/v1/grades
- web: lab edit page (metadata + per-variant forms + add/delete variant)
- web: group page shows grades matrix per lab with editable score inputs
  and computed average column
- web: student dashboard renders statement via markdown, shows deadline
This commit is contained in:
2026-09-11 00:03:24 +03:00
parent 0de6d3f2d8
commit 095e337136
18 changed files with 567 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import jakarta.validation.Valid
import ru.ysu.lms.academic.lab.* import ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@@ -30,4 +31,27 @@ class LabController(private val labService: LabService) {
fun delete(@PathVariable id: UUID) { fun delete(@PathVariable id: UUID) {
labService.delete(id) labService.delete(id)
} }
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
labService.update(id, body)
@PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
labService.addVariant(id, body)
@PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun updateVariant(
@PathVariable id: UUID,
@PathVariable variantId: UUID,
@Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.updateVariant(id, variantId, body)
@DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
labService.deleteVariant(id, variantId)
} }

View File

@@ -78,4 +78,20 @@ data class VariantDetail(
val number: String, val number: String,
val title: String, val title: String,
val statementMd: String, val statementMd: String,
)
data class LabUpdateRequest(
val title: String,
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: BigDecimal,
val status: String,
val descriptionMd: String,
)
data class VariantSaveRequest(
val labLevelId: String,
val number: String,
val title: String,
val statementMd: String,
) )

View File

@@ -12,6 +12,10 @@ import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@Service @Service
@@ -135,4 +139,68 @@ class LabService(
} }
labs.deleteById(labId) labs.deleteById(labId)
} }
@Transactional
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
lab.title = request.title.trim()
lab.deadlineAt = parseInstant(request.deadlineAt)
lab.defenseAt = parseInstant(request.defenseAt)
lab.pointsMax = request.pointsMax
lab.status = request.status
lab.descriptionMd = request.descriptionMd
labs.save(lab)
return detail(labId)
}
@Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim()
variant.title = request.title.trim()
variant.statementMd = request.statementMd
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
}
variantsRepo.save(variant)
return detail(labId)
}
@Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
val level = labLevels.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = request.number.trim(),
title = request.title.trim(),
statementMd = request.statementMd,
))
return detail(labId)
}
@Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant)
return detail(labId)
}
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) }
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
.recoverCatching { LocalDateTime.parse(v).atOffset(ZoneOffset.UTC).toInstant() }
.getOrNull()
}
} }

View File

@@ -20,7 +20,7 @@ spring:
- id: progress - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/** - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**
management: management:
endpoints: endpoints:

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.math.BigDecimal
data class LevelAssignmentRequest( data class LevelAssignmentRequest(
val studentId: String, val studentId: String,
@@ -120,4 +121,16 @@ data class LabStudentsResponse(
data class GroupAssignmentResponse( data class GroupAssignmentResponse(
val groupName: String, val groupName: String,
val students: List<EnrichedAssignment>, val students: List<EnrichedAssignment>,
)
data class GradeSetRequest(
val studentId: String,
val labId: String,
val score: BigDecimal,
)
data class GradeResponse(
val studentId: String,
val labId: String,
val score: BigDecimal,
) )

View File

@@ -64,4 +64,16 @@ class ProgressController(
@RequestParam labId: UUID, @RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue) ): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue)
@PostMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
@GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades()
@GetMapping("/me/grades")
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
service.listGrades().filter { it.studentId == jwt.subject }
} }

View File

@@ -2,17 +2,23 @@ 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.common.BadRequestException
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.VariantAssignment import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import ru.ysu.lms.progress.domain.StudentLabLevelKey import ru.ysu.lms.progress.domain.StudentLabLevelKey
import java.math.BigDecimal
import java.util.UUID import java.util.UUID
@Service @Service
class ProgressService( class ProgressService(
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository,
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient, private val identity: IdentityClient,
) { ) {
@@ -169,6 +175,22 @@ class ProgressService(
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
assignments.findByStudentId(studentId) assignments.findByStudentId(studentId)
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val key = LabGradeKey(UUID.fromString(request.studentId), UUID.fromString(request.labId))
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
return GradeResponse(request.studentId, request.labId, request.score)
}
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> =
grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null

View File

@@ -0,0 +1,26 @@
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.math.BigDecimal
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "lab_grades")
@IdClass(LabGradeKey::class)
class LabGrade(
@Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO,
@Column(nullable = false) var updatedAt: Instant = Instant.now(),
)
data class LabGradeKey(
val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : Serializable

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import java.util.UUID
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
fun findAllBy(): List<LabGrade>
fun findByStudentId(studentId: UUID): List<LabGrade>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
}

View File

@@ -0,0 +1,9 @@
-- Lab grades: one grade per student per lab.
CREATE TABLE lab_grades (
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
score numeric(4,1) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, lab_id)
);

View File

@@ -16,7 +16,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
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.AssignVariantPayload import ru.ysu.lms.web.api.AssignVariantPayload
import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.LabImportResultDto import ru.ysu.lms.web.api.LabImportResultDto
import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.DistributeResultDto import ru.ysu.lms.web.api.DistributeResultDto
import ru.ysu.lms.web.api.LevelAssignPayload import ru.ysu.lms.web.api.LevelAssignPayload
import ru.ysu.lms.web.api.LevelAssignmentResultDto import ru.ysu.lms.web.api.LevelAssignmentResultDto
@@ -90,10 +93,45 @@ class AdminController(private val api: ApiClient) {
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
model.addAttribute("group", api.groupDetail(token(session), id)) val t = token(session)
val group = api.groupDetail(t, id)
val labs = api.listLabs(t)
val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val studentGrades = group.students.map { s ->
val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] }
val nums = scores.mapNotNull { it?.toBigDecimalOrNull() }
AdminStudentGrades(
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
scores = scores,
average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(),
)
}
model.addAttribute("group", group)
model.addAttribute("labs", labs)
model.addAttribute("studentGrades", studentGrades)
return "admin/group" return "admin/group"
} }
@PostMapping("/groups/{id}/grades")
fun setGrade(
@PathVariable id: String,
@RequestParam studentId: String,
@RequestParam labId: String,
@RequestParam score: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.setGrade(token(session), GradeSetPayload(studentId, labId, score))
redirect.addFlashAttribute("notice", "Оценка сохранена")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@PostMapping("/groups/{id}/students/{studentId}/reset-password") @PostMapping("/groups/{id}/students/{studentId}/reset-password")
fun resetPassword( fun resetPassword(
@PathVariable id: String, @PathVariable id: String,
@@ -228,6 +266,77 @@ class AdminController(private val api: ApiClient) {
"redirect:/admin/labs/$labId" "redirect:/admin/labs/$labId"
} }
@GetMapping("/labs/{id}/edit")
fun editLabForm(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val lab = api.labDetail(token(session), id)
// datetime-local требует формат yyyy-MM-dd'T'HH:mm
model.addAttribute("lab", lab)
model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
return "admin/lab-edit"
}
@PostMapping("/labs/{id}/edit")
fun editLab(
@PathVariable id: String,
@RequestParam title: String,
@RequestParam(required = false) deadlineAt: String?,
@RequestParam(required = false) defenseAt: String?,
@RequestParam pointsMax: String,
@RequestParam status: String,
@RequestParam descriptionMd: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.updateLab(
token(session), id,
LabUpdatePayload(title, deadlineAt?.takeIf { it.isNotBlank() }, defenseAt?.takeIf { it.isNotBlank() }, pointsMax, status, descriptionMd),
)
redirect.addFlashAttribute("notice", "Лабораторная сохранена")
"redirect:/admin/labs/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$id/edit"
}
@PostMapping("/labs/{labId}/variants/save")
fun saveVariant(
@PathVariable labId: String,
@RequestParam(required = false) variantId: String?,
@RequestParam labLevelId: String,
@RequestParam number: String,
@RequestParam title: String,
@RequestParam statementMd: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.saveVariant(token(session), labId, variantId, VariantSavePayload(labLevelId, number, title, statementMd))
redirect.addFlashAttribute("notice", "Вариант сохранён")
"redirect:/admin/labs/$labId/edit"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId/edit"
}
@PostMapping("/labs/{labId}/variants/{variantId}/delete")
fun deleteVariant(
@PathVariable labId: String,
@PathVariable variantId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteVariant(token(session), labId, variantId)
redirect.addFlashAttribute("notice", "Вариант удалён")
"redirect:/admin/labs/$labId/edit"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId/edit"
}
@PostMapping("/labs/{id}/distribute") @PostMapping("/labs/{id}/distribute")
fun distribute( fun distribute(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -15,4 +15,12 @@ data class AdminLabStudent(
val variantId: String?, val variantId: String?,
val variantNumber: String?, val variantNumber: String?,
val source: String, val source: String,
)
data class AdminStudentGrades(
val studentId: String,
val fullName: String,
val studentNo: String,
val scores: List<String?>,
val average: Double?,
) )

View File

@@ -235,6 +235,62 @@ class ApiClient(
} ?: emptyList() } ?: emptyList()
} }
fun updateLab(token: String, labId: String, payload: LabUpdatePayload): LabDetailDto =
call {
client.put().uri("/api/v1/labs/{id}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(LabDetailDto::class.java)
}
fun saveVariant(token: String, labId: String, variantId: String?, payload: VariantSavePayload): LabDetailDto =
if (variantId.isNullOrBlank()) {
addVariant(token, labId, payload)
} else {
call {
client.put().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(LabDetailDto::class.java)
}
}
fun addVariant(token: String, labId: String, payload: VariantSavePayload): LabDetailDto =
call {
client.post().uri("/api/v1/labs/{id}/variants", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(LabDetailDto::class.java)
}
fun deleteVariant(token: String, labId: String, variantId: String): LabDetailDto =
call {
client.delete().uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(LabDetailDto::class.java)
}
fun listGrades(token: String): List<GradeDto> {
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
return call {
client.get().uri("/api/v1/grades")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun setGrade(token: String, payload: GradeSetPayload): GradeDto =
call {
client.post().uri("/api/v1/grades")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(GradeDto::class.java)
}
private companion object { private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
} }

View File

@@ -162,6 +162,9 @@ data class LabDetailDto(
val title: String = "", val title: String = "",
val status: String = "", val status: String = "",
val descriptionMd: String = "", val descriptionMd: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: Any? = null,
val levels: List<LabLevelDetailDto> = emptyList(), val levels: List<LabLevelDetailDto> = emptyList(),
) )
@@ -181,6 +184,35 @@ data class VariantDetailDto(
val statementMd: String = "", val statementMd: String = "",
) )
data class LabUpdatePayload(
val title: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: String,
val status: String,
val descriptionMd: String,
)
data class VariantSavePayload(
val labLevelId: String,
val number: String,
val title: String,
val statementMd: String,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GradeDto(
val studentId: String = "",
val labId: String = "",
val score: String = "",
)
data class GradeSetPayload(
val studentId: String,
val labId: String,
val score: String,
)
class DownloadDto( class DownloadDto(
val bytes: ByteArray, val bytes: ByteArray,
val filename: String, val filename: String,

View File

@@ -163,6 +163,7 @@ tr:last-child td { border-bottom: none; }
.inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; } .inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; }
.inline-form .input-sm { width: auto; min-width: 10rem; } .inline-form .input-sm { width: auto; min-width: 10rem; }
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; } .input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
.input-score { width: 4.5rem; }
.filter-select { width: auto; } .filter-select { width: auto; }
.hidden { display: none !important; } .hidden { display: none !important; }

View File

@@ -3,36 +3,101 @@
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head> <head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body> <body>
<div id="content"> <div id="content">
<a th:href="@{/admin/groups}" class="text-sm">← Все группы</a> <a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div><h1 class="page-title" th:text="${group.name}">Группа</h1><p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p></div> <div>
<div class="flex gap-2"><a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a><a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a></div> <h1 class="page-title" th:text="${group.name}">Группа</h1>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
</div>
<div class="flex gap-2">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div>
</div> </div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div> <div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div> <div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div th:if="${resetPassword}" class="alert alert-warning">Новый пароль: <code class="code" th:text="${resetPassword}">password</code></div> <div th:if="${resetPassword}" class="alert alert-warning">
Новый пароль: <code class="code" th:text="${resetPassword}">password</code>
</div>
<div th:if="${importResult}" class="card import-card mb-4"> <div th:if="${importResult}" class="card import-card mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="section-title">Импорт: <span th:text="${importResult.groupName}">группа</span></h2> <h2 class="section-title">Импорт: <span th:text="${importResult.groupName}">группа</span></h2>
<p class="text-sm text-muted">Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>, обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.</p> <p class="text-sm text-muted">
Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>,
обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.
</p>
<div th:if="${not #lists.isEmpty(importResult.added)}" class="mt-4 overflow-x-auto"> <div th:if="${not #lists.isEmpty(importResult.added)}" class="mt-4 overflow-x-auto">
<p class="text-sm alert-warning mini-alert">Сохраните пароли — они показываются для выдачи студентам.</p> <p class="text-sm alert-warning mini-alert">Сохраните пароли — они показываются для выдачи студентам.</p>
<table><thead><tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr></thead> <table>
<tbody><tr th:each="credential : ${importResult.added}"><td th:text="${credential.fullName}">—</td><td class="font-mono" th:text="${credential.studentNo}">—</td><td class="font-mono font-semibold" th:text="${credential.password}">—</td></tr></tbody> <thead><tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr></thead>
<tbody>
<tr th:each="credential : ${importResult.added}">
<td th:text="${credential.fullName}">—</td>
<td class="font-mono" th:text="${credential.studentNo}">—</td>
<td class="font-mono font-semibold" th:text="${credential.password}">—</td>
</tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<h2 class="section-title">Баллы за лабораторные</h2>
<p class="text-sm text-muted mb-4">Введите балл (0–5) и нажмите «OK». Средний балл считается по оценённым лабам.</p>
<div class="card card-flush mb-6 overflow-x-auto">
<table>
<thead>
<tr>
<th>Студент</th>
<th class="text-center" th:each="lab : ${labs}">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
</th>
<th class="text-center">Средний</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${studentGrades}">
<td th:text="${row.fullName}">—</td>
<td th:each="score, iter : ${row.scores}">
<form class="inline-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<input type="hidden" name="labId" th:value="${labs[iter.index].id}">
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5"
th:value="${score}">
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
</form>
</td>
<td class="text-center font-semibold">
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
<span th:if="${row.average == null}" class="text-muted">—</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(group.students)}">
<td class="empty" th:colspan="${#lists.size(labs) + 2}">В группе нет студентов.</td>
</tr>
</tbody>
</table>
</div>
<h2 class="section-title">Студенты</h2>
<div class="card card-flush"> <div class="card card-flush">
<table><thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Пароль</th><th></th></tr></thead> <table>
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Пароль</th><th></th></tr></thead>
<tbody> <tbody>
<tr th:each="student, iter : ${group.students}"> <tr th:each="student, iter : ${group.students}">
<td class="text-muted" th:text="${iter.count}">1</td> <td class="text-muted" th:text="${iter.count}">1</td>
<td th:text="${student.fullName}">—</td> <td th:text="${student.fullName}">—</td>
<td class="font-mono" th:text="${student.studentNo}">—</td> <td class="font-mono" th:text="${student.studentNo}">—</td>
<td class="text-muted" th:text="${student.basis}">—</td> <td class="text-muted" th:text="${student.basis}">—</td>
<td><span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span><span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td> <td><span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span>
<td class="text-center"><form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post"><button type="submit" class="nav-link">Сбросить</button></form></td> <span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
<td class="text-center">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
<button class="nav-link text-sm" type="submit">Сбросить</button>
</form>
</td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="6" class="empty">В группе пока нет студентов.</td></tr> <tr th:if="${#lists.isEmpty(group.students)}"><td colspan="6" class="empty">В группе пока нет студентов.</td></tr>
</tbody> </tbody>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="|Правка: ${lab.title} — LMS|">Правка лабы</title></head>
<body>
<div id="content">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
<h1 class="page-title mt-4">Правка лабораторной</h1>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<form class="card form-card form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="title">Название</label>
<input class="input" id="title" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="deadlineAt">Срок сдачи</label>
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local"
th:value="${deadlineLocal}"></div>
<div><label class="label" for="defenseAt">Дата защиты</label>
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local"
th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="pointsMax">Максимум баллов</label>
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0"
th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="status">Статус</label>
<select class="input" id="status" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
<option value="published" th:selected="${lab.status == 'published'}">published</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14"
th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
<h2 class="section-title mt-8">Варианты</h2>
<div th:each="level : ${lab.levels}" class="mb-6">
<h3 class="group-title" th:text="${level.title}">Уровень</h3>
<div th:each="v : ${level.variants}" class="card form-card form-stack mb-4">
<form th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="6" th:text="${v.statementMd}"></textarea></div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
<form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button>
</form>
</div>
</div>
<h2 class="section-title mt-8">Добавить вариант</h2>
<form class="card form-card form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="newLevel">Уровень</label>
<select class="input" id="newLevel" name="labLevelId">
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}">—</option>
</select></div>
<div><label class="label" for="newNumber">Номер</label>
<input class="input" id="newNumber" name="number" required></div>
</div>
<div><label class="label" for="newTitle">Название</label>
<input class="input" id="newTitle" name="title"></div>
<div><label class="label" for="newStatement">Условие (markdown)</label>
<textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
</form>
</div>
</body>
</html>

View File

@@ -10,6 +10,7 @@
<p class="text-sm text-muted">Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></p> <p class="text-sm text-muted">Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post"> <form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button> <button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
</form> </form>