Complete distribution flow and student UI

- progress-service: Distribute variants (round-robin per lab per level),
  student level assignment, API for levels/assignments
- progress-service: Inter-service clients (academic, identity) via RestClient
- web: Admin lab detail page with distribute/level buttons
- web: Student dashboard shows assigned labs from progress-service
- web: ApiClient methods for labs detail, distribute, levels, me/assignments
- academic-service: Add id to LabLevelDetail DTO for progress-service
- Fix: CSS-only layout, footer at bottom via flex column
This commit is contained in:
2026-09-10 22:18:47 +03:00
parent b4c801853a
commit 84532adbff
18 changed files with 753 additions and 26 deletions

View File

@@ -67,6 +67,7 @@ data class LabDetail(
)
data class LabLevelDetail(
val id: String = "",
val code: String,
val title: String,
val variants: List<VariantDetail>,

View File

@@ -112,6 +112,7 @@ class LabService(
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants = vs.map { v ->

View File

@@ -0,0 +1,46 @@
package ru.ysu.lms.progress
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import ru.ysu.lms.common.NotFoundException
@Service
class AcademicClient(
@Value("\${lms.academic.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getLab(labId: String, token: String): LabInfo =
client.get().uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена")
fun getLabSummary(labId: String, token: String): LabInfo =
client.get().uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена")
}
@Service
class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getGroup(groupId: String, token: String): GroupInfo =
client.get().uri("/api/v1/groups/{id}", groupId)
.header("Authorization", "Bearer $token")
.retrieve().body(GroupInfo::class.java)
?: throw NotFoundException("Группа не найдена")
fun listGroups(token: String): List<Map<String, Any>> {
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
return client.get().uri("/api/v1/groups")
.header("Authorization", "Bearer $token")
.retrieve().body(type) ?: emptyList()
}
}

View File

@@ -0,0 +1,84 @@
package ru.ysu.lms.progress
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
data class LevelAssignmentRequest(
val studentId: String,
val labId: String,
val labLevelId: String,
)
data class LevelAssignmentResponse(
val studentId: String,
val labId: String,
val labLevelId: String,
val manual: Boolean,
)
data class DistributeRequest(
val labId: String,
/** If true, re-assigns even existing manual assignments */
val force: Boolean = false,
)
data class VariantAssignmentResponse(
val studentId: String,
val labId: String,
val variantId: String,
val variantNumber: String,
val manual: Boolean,
)
data class DistributeResult(
val labId: String,
val labTitle: String,
val assigned: Int,
val total: Int,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentInfo(
val id: String = "",
val studentNo: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupInfo(
val id: String = "",
val name: String = "",
val students: List<StudentInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabInfo(
val id: String = "",
val number: Int = 0,
val title: String = "",
val levels: List<LevelInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LevelInfo(
val code: String = "",
val title: String = "",
val id: String = "",
val variants: List<VariantInfo> = emptyList(),
)
data class VariantInfo(
val id: String = "",
val number: String = "",
val title: String = "",
)
data class MeAssignmentResponse(
val labNumber: Int,
val labTitle: String,
val variantNumber: String,
val variantTitle: String,
val statementMd: String,
val levelTitle: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: Any?,
)

View File

@@ -0,0 +1,50 @@
package ru.ysu.lms.progress
import jakarta.validation.Valid
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class ProgressController(private val service: ProgressService) {
@GetMapping("/me/assignments")
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
val sid = UUID.fromString(jwt.subject)
return service.getStudentAssignments(sid).mapNotNull { va ->
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
}
}
@GetMapping("/me/labs")
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
return listOf()
}
@PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setLevel(@Valid @RequestBody body: LevelAssignmentRequest): LevelAssignmentResponse =
service.setLevel(body)
@GetMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
service.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse(
studentId = sll.studentId.toString(),
labId = sll.labId.toString(),
labLevelId = sll.labLevelId.toString(),
manual = sll.manual,
)
}
@PostMapping("/distribute")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun distribute(
@Valid @RequestBody body: DistributeRequest,
@AuthenticationPrincipal jwt: Jwt,
): DistributeResult = service.distribute(body, jwt.tokenValue)
}

View File

@@ -0,0 +1,151 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import java.util.UUID
@Service
class ProgressService(
private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository,
private val academic: AcademicClient,
private val identity: IdentityClient,
) {
@Transactional
fun setLevel(request: LevelAssignmentRequest): LevelAssignmentResponse {
val key = StudentLabLevelKey(
UUID.fromString(request.studentId),
UUID.fromString(request.labId),
)
val existing = levels.findById(key)
val entity = existing.orElse(StudentLabLevel(
studentId = UUID.fromString(request.studentId),
labId = UUID.fromString(request.labId),
))
entity.labLevelId = UUID.fromString(request.labLevelId)
entity.manual = true
levels.save(entity)
return LevelAssignmentResponse(
studentId = request.studentId,
labId = request.labId,
labLevelId = request.labLevelId,
manual = true,
)
}
@Transactional(readOnly = true)
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
levels.findByStudentIdAndLabId(studentId, labId)
@Transactional(readOnly = true)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
levels.findByLabId(labId)
@Transactional
fun distribute(request: DistributeRequest, token: String): DistributeResult {
val labId = UUID.fromString(request.labId)
val lab = academic.getLab(request.labId, token)
val levelMap = lab.levels.associateBy { it.id }
if (request.force) {
assignments.deleteByLabId(labId)
}
val groups = identity.listGroups(token)
val students = mutableListOf<Pair<UUID, UUID>>() // (studentId, labLevelId)
for (g in groups) {
val gid = g["id"] as String
val group = identity.getGroup(gid, token)
for (s in group.students) {
val sid = UUID.fromString(s.id)
val existingLevel = levels.findByStudentIdAndLabId(sid, labId)
val levelId = if (existingLevel != null) {
existingLevel.labLevelId
} else {
val levelInfo = lab.levels.firstOrNull() ?: continue
UUID.fromString(levelInfo.id)
}
students.add(sid to levelId)
}
}
val assigned = mutableSetOf<UUID>()
val variantIdsByLevel = lab.levels.associate { lv ->
lv.id to lv.variants.map { it.id }
}
val result = mutableListOf<VariantAssignment>()
for ((sid, levelId) in students) {
val existing = assignments.findByStudentIdAndLabId(sid, labId)
if (existing != null && existing.manual) {
result.add(existing)
assigned.add(existing.variantId)
continue
}
if (existing != null && !request.force) {
result.add(existing)
assigned.add(existing.variantId)
continue
}
val pool = variantIdsByLevel[levelId.toString()].orEmpty()
if (pool.isEmpty()) continue
val chosen = pool.firstOrNull { it !in assigned }
?: pool.first()
val entity = VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
)
assignments.save(entity)
result.add(entity)
assigned.add(UUID.fromString(chosen))
}
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
return DistributeResult(
labId = request.labId,
labTitle = title,
assigned = result.size,
total = students.size,
)
}
@Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
assignments.findByLabId(labId)
@Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
assignments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
return enrich(va, token)
}
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
val lab = runCatching { academic.getLabSummary(va.labId.toString(), token) }.getOrNull() ?: return null
val firstLevel = lab.levels.firstOrNull()
return MeAssignmentResponse(
labNumber = lab.number,
labTitle = lab.title,
variantNumber = "",
variantTitle = "",
statementMd = "",
levelTitle = firstLevel?.title ?: "",
deadlineAt = null,
defenseAt = null,
pointsMax = null,
)
}
}

View File

@@ -0,0 +1,23 @@
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.util.UUID
@Entity
@Table(name = "student_lab_levels")
@IdClass(StudentLabLevelKey::class)
class StudentLabLevel(
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0),
@Id @Column(nullable = false) var labId: UUID = UUID(0,0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
)
data class StudentLabLevelKey(
val studentId: UUID = UUID(0,0),
val labId: UUID = UUID(0,0),
) : java.io.Serializable

View File

@@ -17,17 +17,4 @@ class VariantAssignment(
@Column(nullable = false) var variantId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
)
@Entity
@Table(name = "student_lab_levels")
class StudentLabLevel(
@Id
@Column(name = "student_id", insertable = false, updatable = false)
var studentId: UUID = UUID(0,0),
@Id
@Column(name = "lab_id", insertable = false, updatable = false)
var labId: UUID = UUID(0,0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
)

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import java.util.UUID
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
fun findByLabId(labId: UUID): List<StudentLabLevel>
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
fun deleteByLabId(labId: UUID)
}

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.VariantAssignment
import java.util.UUID
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
fun findByStudentId(studentId: UUID): List<VariantAssignment>
fun findByLabId(labId: UUID): List<VariantAssignment>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
fun deleteByLabId(labId: UUID)
}

View File

@@ -27,4 +27,10 @@ management:
include: health,info
springdoc:
api-docs:
path: /v3/api-docs
path: /v3/api-docs
lms:
academic:
base-url: ${LMS_ACADEMIC_URI:http://localhost:8082}
identity:
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}

View File

@@ -15,6 +15,11 @@ import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.LabImportResultDto
import ru.ysu.lms.web.api.DistributeResultDto
import ru.ysu.lms.web.api.LevelAssignPayload
import ru.ysu.lms.web.api.LevelAssignmentResultDto
import ru.ysu.lms.web.api.DistributeRequestPayload
import ru.ysu.lms.web.session.SessionKeys
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -118,6 +123,94 @@ class AdminController(private val api: ApiClient) {
.body(download.bytes)
}
@GetMapping("/labs")
fun labs(session: HttpSession, model: Model): String {
model.addAttribute("labs", api.listLabs(token(session)))
return "admin/labs"
}
@PostMapping("/labs/import")
fun importLab(
@RequestParam("file") file: MultipartFile,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл")
return "redirect:/admin/labs"
}
return try {
val result = api.importLab(
token(session),
file.originalFilename ?: "lab.zip",
file.bytes,
)
redirect.addFlashAttribute("notice", "Лабораторная ${result.number}: «${result.title}» импортирована (${result.variantCount} вариантов)")
"redirect:/admin/labs"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs"
}
}
@PostMapping("/labs/{id}/delete")
fun deleteLab(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteLab(token(session), id)
redirect.addFlashAttribute("notice", "Лабораторная удалена")
"redirect:/admin/labs"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs"
}
@GetMapping("/labs/{id}")
fun labDetail(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val lab = api.labDetail(token(session), id)
val levels = api.getLevels(token(session), id)
model.addAttribute("lab", lab)
model.addAttribute("levelAssignments", levels)
return "admin/lab"
}
@PostMapping("/labs/{id}/distribute")
fun distribute(
@PathVariable id: String,
@RequestParam(defaultValue = "false") force: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
val result = api.distribute(token(session), DistributeRequestPayload(labId = id, force = force))
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
"redirect:/admin/labs/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$id"
}
@PostMapping("/labs/{labId}/set-level")
fun setLevel(
@PathVariable labId: String,
@RequestParam studentId: String,
@RequestParam labLevelId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId))
redirect.addFlashAttribute("notice", "Уровень студента установлен")
"redirect:/admin/labs/$labId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId"
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")

View File

@@ -143,6 +143,80 @@ class ApiClient(
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
}
fun listLabs(token: String): List<LabSummaryDto> {
val type = object : ParameterizedTypeReference<List<LabSummaryDto>>() {}
return call {
client.get().uri("/api/v1/labs")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun labDetail(token: String, labId: String): LabDetailDto =
call {
client.get().uri("/api/v1/labs/{id}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(LabDetailDto::class.java)
}
fun importLab(token: String, filename: String, bytes: ByteArray): LabImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add("file", object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename
})
return call {
client.post().uri("/api/v1/labs/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve().body(LabImportResultDto::class.java)
}
}
fun deleteLab(token: String, labId: String) {
call {
client.delete().uri("/api/v1/labs/{id}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun getLevels(token: String, labId: String): List<LevelAssignmentResultDto> {
val type = object : ParameterizedTypeReference<List<LevelAssignmentResultDto>>() {}
return call {
client.get().uri("/api/v1/levels?labId={labId}", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun setLevel(token: String, payload: LevelAssignPayload): LevelAssignmentResultDto =
call {
client.post().uri("/api/v1/levels")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(LevelAssignmentResultDto::class.java)
}
fun distribute(token: String, payload: DistributeRequestPayload): DistributeResultDto =
call {
client.post().uri("/api/v1/distribute")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(DistributeResultDto::class.java)
}
fun meAssignments(token: String): List<MeAssignmentDto> {
val type = object : ParameterizedTypeReference<List<MeAssignmentDto>>() {}
return call {
client.get().uri("/api/v1/me/assignments")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
}

View File

@@ -71,6 +71,92 @@ data class GroupDetailDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "")
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabSummaryDto(
val id: String = "",
val number: Int = 0,
val title: String = "",
val status: String = "",
val semesterTitle: String = "",
val pointsMax: Any? = null,
val variantCount: Int = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabImportResultDto(
val labId: String = "",
val number: Int = 0,
val title: String = "",
val levelCount: Int = 0,
val variantCount: Int = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DistributeResultDto(
val labId: String = "",
val labTitle: String = "",
val assigned: Int = 0,
val total: Int = 0,
)
data class DistributeRequestPayload(
val labId: String,
val force: Boolean = false,
)
data class LevelAssignPayload(
val studentId: String,
val labId: String,
val labLevelId: String,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LevelAssignmentResultDto(
val studentId: String = "",
val labId: String = "",
val labLevelId: String = "",
val manual: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MeAssignmentDto(
val labNumber: Int = 0,
val labTitle: String = "",
val variantNumber: String = "",
val variantTitle: String = "",
val statementMd: String = "",
val levelTitle: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: Any? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabDetailDto(
val id: String = "",
val number: Int = 0,
val title: String = "",
val status: String = "",
val descriptionMd: String = "",
val levels: List<LabLevelDetailDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabLevelDetailDto(
val id: String = "",
val code: String = "",
val title: String = "",
val variants: List<VariantDetailDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class VariantDetailDto(
val id: String = "",
val number: String = "",
val title: String = "",
val statementMd: String = "",
)
class DownloadDto(
val bytes: ByteArray,
val filename: String,

View File

@@ -1,13 +1,22 @@
package ru.ysu.lms.web.student
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.session.SessionKeys
@Controller
@RequestMapping("/student")
class StudentController {
class StudentController(private val api: ApiClient) {
@GetMapping
fun dashboard(): String = "student/dashboard"
}
fun dashboard(session: HttpSession, model: Model): String {
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
val assignments = api.meAssignments(token)
model.addAttribute("assignments", assignments)
return "student/dashboard"
}
}

View File

@@ -0,0 +1,43 @@
<!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}" class="text-sm">← Все лабораторные</a>
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div><h1 class="page-title" th:text="|${lab.number}. ${lab.title}|">ЛР 1</h1><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>
<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-secondary" type="submit" name="force" value="true">Перераспределить (сбросить ручные)</button>
</form>
</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>
<h2 class="section-title">Уровни вариантов</h2>
<div class="card card-flush mt-2 mb-6">
<table>
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
<tbody>
<tr th:each="level : ${lab.levels}">
<td th:text="${level.code}">—</td>
<td th:text="${level.title}">—</td>
<td th:text="${#lists.size(level.variants)}">0</td>
</tr>
</tbody>
</table>
</div>
<h2 class="section-title">Студенты и варианты</h2>
<p class="text-sm text-muted mb-4">Нажмите «Распределить», чтобы закрепить варианты. Можно задать уровень студента.</p>
<div class="card card-flush">
<table>
<thead><tr><th>Студент</th><th>Уровень</th><th>Вариант</th></tr></thead>
<tbody id="student-rows">
<!-- Filled dynamically after distribution -->
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Лабораторные — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Лабораторные</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>
<div class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт лабораторной</h2>
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json (старый формат).</p>
<div><label class="label" for="labFile">Файл</label><input class="input" id="labFile" name="file" type="file" accept=".zip,.json" required></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
<div class="card card-flush mt-8">
<table>
<thead><tr><th>№</th><th>Название</th><th>Семестр</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td th:text="${lab.title}">—</td>
<td class="text-muted" th:text="${lab.semesterTitle}">—</td>
<td th:text="${lab.variantCount}">0</td>
<td><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td>
<td class="text-center">
<form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post" onsubmit="return confirm('Удалить лабораторную ${lab.title}?')">
<button class="nav-link text-sm" type="submit">Удалить</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -4,15 +4,24 @@
<body>
<div id="content">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
<p class="text-sm text-muted mb-6">Здесь появятся лабораторные работы, условия вашего варианта и материалы занятий.</p>
<div class="card card-body">
<p>Разделы в разработке:</p>
<ul class="text-sm text-muted mt-2 list-pad">
<li>список лабораторных и сроки сдачи;</li>
<li>условие закреплённого за вами варианта;</li>
<li>статьи и материалы занятий;</li>
<li>отправка решений.</li>
</ul>
<p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
</div>
<div class="card card-flush" th:unless="${#lists.isEmpty(assignments)}">
<table>
<thead><tr><th>№</th><th>Название</th><th>Вариант</th><th>Срок сдачи</th><th>Дата защиты</th><th>Статус</th></tr></thead>
<tbody>
<tr th:each="a : ${assignments}">
<td th:text="${a.labNumber}">1</td>
<td th:text="${a.labTitle}">—</td>
<td th:text="${a.variantNumber}">—</td>
<td class="text-sm text-muted" th:text="${a.deadlineAt}">—</td>
<td class="text-sm text-muted" th:text="${a.defenseAt}">—</td>
<td><span class="badge badge-warn">назначено</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>