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( data class LabLevelDetail(
val id: String = "",
val code: String, val code: String,
val title: String, val title: String,
val variants: List<VariantDetail>, val variants: List<VariantDetail>,

View File

@@ -112,6 +112,7 @@ class LabService(
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id) val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id } .filter { it.labLevel?.id == level.id }
LabLevelDetail( LabLevelDetail(
id = level.id.toString(),
code = level.code, code = level.code,
title = level.title, title = level.title,
variants = vs.map { v -> 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 variantId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false, @Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(), @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 include: health,info
springdoc: springdoc:
api-docs: 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 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.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 ru.ysu.lms.web.session.SessionKeys
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -118,6 +123,94 @@ class AdminController(private val api: ApiClient) {
.body(download.bytes) .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 = private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново") ?: throw ApiException(401, "Сессия истекла, войдите заново")

View File

@@ -143,6 +143,80 @@ class ApiClient(
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})" 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 { private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
} }

View File

@@ -71,6 +71,92 @@ data class GroupDetailDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "") 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( class DownloadDto(
val bytes: ByteArray, val bytes: ByteArray,
val filename: String, val filename: String,

View File

@@ -1,13 +1,22 @@
package ru.ysu.lms.web.student package ru.ysu.lms.web.student
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.session.SessionKeys
@Controller @Controller
@RequestMapping("/student") @RequestMapping("/student")
class StudentController { class StudentController(private val api: ApiClient) {
@GetMapping @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> <body>
<div id="content"> <div id="content">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1> <h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
<p class="text-sm text-muted mb-6">Здесь появятся лабораторные работы, условия вашего варианта и материалы занятий.</p> <p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
<div class="card card-body"> <div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
<p>Разделы в разработке:</p> Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
<ul class="text-sm text-muted mt-2 list-pad"> </div>
<li>список лабораторных и сроки сдачи;</li> <div class="card card-flush" th:unless="${#lists.isEmpty(assignments)}">
<li>условие закреплённого за вами варианта;</li> <table>
<li>статьи и материалы занятий;</li> <thead><tr><th>№</th><th>Название</th><th>Вариант</th><th>Срок сдачи</th><th>Дата защиты</th><th>Статус</th></tr></thead>
<li>отправка решений.</li> <tbody>
</ul> <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>
</div> </div>
</body> </body>