Lab assignment management UI and student variant view

- progress-service: POST /api/v1/assign (manual variant), GET /api/v1/assignments?labId=
  (enriched assignments), fixed enrich() to return real variant number/title/statement
- gateway: routes for /api/v1/assign and /api/v1/levels (exact paths)
- web: lab detail page shows students grouped with level and variant selects,
  group filter, distribute/redistribute buttons, source badge (auto/manual)
- web: student dashboard renders variant statement via libs/markdown (spoilers,
  code highlight, sanitized)
- academic-service: unquote YAML front-matter values (variant numbers were quoted)
- academic-service: LabLevelDetail.id for level references
This commit is contained in:
2026-09-10 23:25:36 +03:00
parent 84532adbff
commit 0de6d3f2d8
17 changed files with 408 additions and 67 deletions

View File

@@ -144,19 +144,28 @@ class LabImportParser {
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = parts[1].trim()
if (parts.size == 2) fm[parts[0].trim()] = unquote(parts[1].trim())
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"] ?: return null,
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null,
title = fm["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun unquote(value: String): String =
if (value.length >= 2 &&
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\''))
) {
value.substring(1, value.length - 1)
} else {
value
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"

View File

@@ -20,7 +20,7 @@ spring:
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assignments/**,/api/v1/levels/**,/api/v1/me/**,/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/**
management:
endpoints:

View File

@@ -40,6 +40,7 @@ data class DistributeResult(
data class StudentInfo(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -54,21 +55,28 @@ data class LabInfo(
val id: String = "",
val number: Int = 0,
val title: String = "",
val status: String = "",
val descriptionMd: String = "",
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: Any? = null,
val levels: List<LevelInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LevelInfo(
val id: String = "",
val code: String = "",
val title: String = "",
val id: String = "",
val variants: List<VariantInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class VariantInfo(
val id: String = "",
val number: String = "",
val title: String = "",
val statementMd: String = "",
)
data class MeAssignmentResponse(
@@ -81,4 +89,35 @@ data class MeAssignmentResponse(
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: Any?,
)
data class AssignRequest(
val studentId: String,
val labId: String,
val variantId: String,
)
data class EnrichedAssignment(
val studentId: String,
val studentName: String,
val studentNo: String,
val groupName: String,
val levelCode: String,
val levelTitle: String,
val variantId: String?,
val variantNumber: String?,
val manual: Boolean,
)
data class LabStudentsResponse(
val labId: String,
val labNumber: Int,
val labTitle: String,
val levels: List<LevelInfo>,
val groups: List<GroupAssignmentResponse>,
)
data class GroupAssignmentResponse(
val groupName: String,
val students: List<EnrichedAssignment>,
)

View File

@@ -9,7 +9,10 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class ProgressController(private val service: ProgressService) {
class ProgressController(
private val service: ProgressService,
private val academic: AcademicClient,
) {
@GetMapping("/me/assignments")
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
@@ -47,4 +50,18 @@ class ProgressController(private val service: ProgressService) {
@Valid @RequestBody body: DistributeRequest,
@AuthenticationPrincipal jwt: Jwt,
): DistributeResult = service.distribute(body, jwt.tokenValue)
@PostMapping("/assign")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun assign(
@Valid @RequestBody body: AssignRequest,
@AuthenticationPrincipal jwt: Jwt,
): VariantAssignmentResponse = service.assignWithInfo(body, jwt.tokenValue)
@GetMapping("/assignments")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun assignmentsForLab(
@RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue)
}

View File

@@ -75,7 +75,7 @@ class ProgressService(
}
}
val assigned = mutableSetOf<UUID>()
val assigned = mutableSetOf<String>()
val variantIdsByLevel = lab.levels.associate { lv ->
lv.id to lv.variants.map { it.id }
}
@@ -85,12 +85,12 @@ class ProgressService(
val existing = assignments.findByStudentIdAndLabId(sid, labId)
if (existing != null && existing.manual) {
result.add(existing)
assigned.add(existing.variantId)
assigned.add(existing.variantId.toString())
continue
}
if (existing != null && !request.force) {
result.add(existing)
assigned.add(existing.variantId)
assigned.add(existing.variantId.toString())
continue
}
@@ -107,7 +107,7 @@ class ProgressService(
)
assignments.save(entity)
result.add(entity)
assigned.add(UUID.fromString(chosen))
assigned.add(chosen)
}
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
@@ -123,6 +123,48 @@ class ProgressService(
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
assignments.findByLabId(labId)
@Transactional
fun assign(request: AssignRequest): VariantAssignment {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val existing = assignments.findByStudentIdAndLabId(sid, lid)
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(request.variantId)
manual = true
}
return assignments.save(entity)
}
@Transactional
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
assign(request)
val lab = academic.getLab(request.labId, token)
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
return VariantAssignmentResponse(
studentId = request.studentId,
labId = request.labId,
variantId = request.variantId,
variantNumber = variant?.number ?: "",
manual = true,
)
}
@Transactional(readOnly = true)
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
val lab = academic.getLab(labId.toString(), token)
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
return assignments.findByLabId(labId).map { va ->
val v = variants[va.variantId.toString()]
VariantAssignmentResponse(
studentId = va.studentId.toString(),
labId = va.labId.toString(),
variantId = va.variantId.toString(),
variantNumber = v?.number ?: "",
manual = va.manual,
)
}
}
@Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
assignments.findByStudentId(studentId)
@@ -134,18 +176,19 @@ class ProgressService(
}
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()
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
return MeAssignmentResponse(
labNumber = lab.number,
labTitle = lab.title,
variantNumber = "",
variantTitle = "",
statementMd = "",
levelTitle = firstLevel?.title ?: "",
deadlineAt = null,
defenseAt = null,
pointsMax = null,
variantNumber = variant?.number ?: "",
variantTitle = variant?.title ?: "",
statementMd = variant?.statementMd ?: "",
levelTitle = level?.title ?: "",
deadlineAt = lab.deadlineAt,
defenseAt = lab.defenseAt,
pointsMax = lab.pointsMax,
)
}
}

View File

@@ -2,12 +2,18 @@ package ru.ysu.lms.progress.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@@ -18,6 +24,17 @@ class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
}
return converter
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
@@ -28,7 +45,11 @@ class SecurityConfig {
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt { } }
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}