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}