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:
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?,
|
||||||
)
|
)
|
||||||
@@ -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*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
81
web/src/main/resources/templates/admin/lab-edit.html
Normal file
81
web/src/main/resources/templates/admin/lab-edit.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user