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:
@@ -144,19 +144,28 @@ class LabImportParser {
|
|||||||
val fm = mutableMapOf<String, String>()
|
val fm = mutableMapOf<String, String>()
|
||||||
for (line in fmLines) {
|
for (line in fmLines) {
|
||||||
val parts = line.split(":", limit = 2)
|
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")
|
val levelCode = normalizeLevel(fm["level"] ?: "basic")
|
||||||
if (levelMap[levelCode] == null) return null
|
if (levelMap[levelCode] == null) return null
|
||||||
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
|
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
|
||||||
return VariantMeta(
|
return VariantMeta(
|
||||||
level = levelCode,
|
level = levelCode,
|
||||||
number = fm["number"] ?: return null,
|
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null,
|
||||||
title = fm["title"] ?: "",
|
title = fm["title"] ?: "",
|
||||||
statement = body.ifBlank { "—" },
|
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()) {
|
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
|
||||||
"main", "basic", "обычная", "основная" -> "basic"
|
"main", "basic", "обычная", "основная" -> "basic"
|
||||||
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
||||||
|
|||||||
@@ -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/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:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ data class DistributeResult(
|
|||||||
data class StudentInfo(
|
data class StudentInfo(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val studentNo: String = "",
|
val studentNo: String = "",
|
||||||
|
val fullName: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -54,21 +55,28 @@ data class LabInfo(
|
|||||||
val id: String = "",
|
val id: String = "",
|
||||||
val number: Int = 0,
|
val number: Int = 0,
|
||||||
val title: String = "",
|
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(),
|
val levels: List<LevelInfo> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LevelInfo(
|
data class LevelInfo(
|
||||||
|
val id: String = "",
|
||||||
val code: String = "",
|
val code: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val id: String = "",
|
|
||||||
val variants: List<VariantInfo> = emptyList(),
|
val variants: List<VariantInfo> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class VariantInfo(
|
data class VariantInfo(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val number: String = "",
|
val number: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
|
val statementMd: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MeAssignmentResponse(
|
data class MeAssignmentResponse(
|
||||||
@@ -82,3 +90,34 @@ data class MeAssignmentResponse(
|
|||||||
val defenseAt: String?,
|
val defenseAt: String?,
|
||||||
val pointsMax: Any?,
|
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>,
|
||||||
|
)
|
||||||
@@ -9,7 +9,10 @@ import java.util.UUID
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1")
|
||||||
class ProgressController(private val service: ProgressService) {
|
class ProgressController(
|
||||||
|
private val service: ProgressService,
|
||||||
|
private val academic: AcademicClient,
|
||||||
|
) {
|
||||||
|
|
||||||
@GetMapping("/me/assignments")
|
@GetMapping("/me/assignments")
|
||||||
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
||||||
@@ -47,4 +50,18 @@ class ProgressController(private val service: ProgressService) {
|
|||||||
@Valid @RequestBody body: DistributeRequest,
|
@Valid @RequestBody body: DistributeRequest,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): DistributeResult = service.distribute(body, jwt.tokenValue)
|
): 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)
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val assigned = mutableSetOf<UUID>()
|
val assigned = mutableSetOf<String>()
|
||||||
val variantIdsByLevel = lab.levels.associate { lv ->
|
val variantIdsByLevel = lab.levels.associate { lv ->
|
||||||
lv.id to lv.variants.map { it.id }
|
lv.id to lv.variants.map { it.id }
|
||||||
}
|
}
|
||||||
@@ -85,12 +85,12 @@ class ProgressService(
|
|||||||
val existing = assignments.findByStudentIdAndLabId(sid, labId)
|
val existing = assignments.findByStudentIdAndLabId(sid, labId)
|
||||||
if (existing != null && existing.manual) {
|
if (existing != null && existing.manual) {
|
||||||
result.add(existing)
|
result.add(existing)
|
||||||
assigned.add(existing.variantId)
|
assigned.add(existing.variantId.toString())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (existing != null && !request.force) {
|
if (existing != null && !request.force) {
|
||||||
result.add(existing)
|
result.add(existing)
|
||||||
assigned.add(existing.variantId)
|
assigned.add(existing.variantId.toString())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class ProgressService(
|
|||||||
)
|
)
|
||||||
assignments.save(entity)
|
assignments.save(entity)
|
||||||
result.add(entity)
|
result.add(entity)
|
||||||
assigned.add(UUID.fromString(chosen))
|
assigned.add(chosen)
|
||||||
}
|
}
|
||||||
|
|
||||||
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
|
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
|
||||||
@@ -123,6 +123,48 @@ class ProgressService(
|
|||||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
||||||
assignments.findByLabId(labId)
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
||||||
assignments.findByStudentId(studentId)
|
assignments.findByStudentId(studentId)
|
||||||
@@ -134,18 +176,19 @@ class ProgressService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
|
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
|
||||||
val lab = runCatching { academic.getLabSummary(va.labId.toString(), token) }.getOrNull() ?: return null
|
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
|
||||||
val firstLevel = lab.levels.firstOrNull()
|
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(
|
return MeAssignmentResponse(
|
||||||
labNumber = lab.number,
|
labNumber = lab.number,
|
||||||
labTitle = lab.title,
|
labTitle = lab.title,
|
||||||
variantNumber = "",
|
variantNumber = variant?.number ?: "",
|
||||||
variantTitle = "",
|
variantTitle = variant?.title ?: "",
|
||||||
statementMd = "",
|
statementMd = variant?.statementMd ?: "",
|
||||||
levelTitle = firstLevel?.title ?: "",
|
levelTitle = level?.title ?: "",
|
||||||
deadlineAt = null,
|
deadlineAt = lab.deadlineAt,
|
||||||
defenseAt = null,
|
defenseAt = lab.defenseAt,
|
||||||
pointsMax = null,
|
pointsMax = lab.pointsMax,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,18 @@ package ru.ysu.lms.progress.security
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
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.method.configuration.EnableMethodSecurity
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
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.JwtDecoder
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -18,6 +24,17 @@ class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
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
|
@Bean
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
http
|
http
|
||||||
@@ -28,7 +45,11 @@ class SecurityConfig {
|
|||||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
.oauth2ResourceServer { it.jwt { } }
|
.oauth2ResourceServer { oauth ->
|
||||||
|
oauth.jwt { jwt ->
|
||||||
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||||
|
}
|
||||||
|
}
|
||||||
return http.build()
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies {
|
|||||||
implementation(libs.springBootStarterActuator)
|
implementation(libs.springBootStarterActuator)
|
||||||
|
|
||||||
implementation(project(":libs:common-http"))
|
implementation(project(":libs:common-http"))
|
||||||
|
implementation(project(":libs:markdown"))
|
||||||
|
|
||||||
developmentOnly(platform(libs.springBootBom))
|
developmentOnly(platform(libs.springBootBom))
|
||||||
developmentOnly(libs.springBootDevtools)
|
developmentOnly(libs.springBootDevtools)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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.AssignVariantPayload
|
||||||
import ru.ysu.lms.web.api.LabImportResultDto
|
import ru.ysu.lms.web.api.LabImportResultDto
|
||||||
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
|
||||||
@@ -173,13 +174,60 @@ class AdminController(private val api: ApiClient) {
|
|||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val lab = api.labDetail(token(session), id)
|
val t = token(session)
|
||||||
val levels = api.getLevels(token(session), id)
|
val lab = api.labDetail(t, id)
|
||||||
|
val levelAssignments = api.getLevels(t, id)
|
||||||
|
val variantAssignments = api.getLabAssignments(t, id)
|
||||||
|
|
||||||
|
// Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом
|
||||||
|
val groupSections = api.listGroups(t).map { group ->
|
||||||
|
val detail = api.groupDetail(t, group.id)
|
||||||
|
AdminLabGroup(
|
||||||
|
groupName = group.name,
|
||||||
|
students = detail.students.map { s ->
|
||||||
|
val level = levelAssignments.firstOrNull { it.studentId == s.id }
|
||||||
|
val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty()
|
||||||
|
val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—"
|
||||||
|
val va = variantAssignments.firstOrNull { it.studentId == s.id }
|
||||||
|
AdminLabStudent(
|
||||||
|
studentId = s.id,
|
||||||
|
fullName = s.fullName,
|
||||||
|
studentNo = s.studentNo,
|
||||||
|
levelId = levelId,
|
||||||
|
levelTitle = levelTitle,
|
||||||
|
levelChanged = level != null,
|
||||||
|
variantId = va?.variantId,
|
||||||
|
variantNumber = va?.variantNumber,
|
||||||
|
source = when {
|
||||||
|
va == null -> "не распределён"
|
||||||
|
va.manual -> "вручную"
|
||||||
|
else -> "авто"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
model.addAttribute("lab", lab)
|
model.addAttribute("lab", lab)
|
||||||
model.addAttribute("levelAssignments", levels)
|
model.addAttribute("groups", groupSections)
|
||||||
return "admin/lab"
|
return "admin/lab"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{labId}/assign")
|
||||||
|
fun assignVariant(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
@RequestParam studentId: String,
|
||||||
|
@RequestParam variantId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String = try {
|
||||||
|
val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId))
|
||||||
|
redirect.addFlashAttribute("notice", "Назначен вариант ${result.variantNumber}")
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$labId"
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/labs/{id}/distribute")
|
@PostMapping("/labs/{id}/distribute")
|
||||||
fun distribute(
|
fun distribute(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
|
|||||||
18
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt
Normal file
18
web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
data class AdminLabGroup(
|
||||||
|
val groupName: String,
|
||||||
|
val students: List<AdminLabStudent>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminLabStudent(
|
||||||
|
val studentId: String,
|
||||||
|
val fullName: String,
|
||||||
|
val studentNo: String,
|
||||||
|
val levelId: String,
|
||||||
|
val levelTitle: String,
|
||||||
|
val levelChanged: Boolean,
|
||||||
|
val variantId: String?,
|
||||||
|
val variantNumber: String?,
|
||||||
|
val source: String,
|
||||||
|
)
|
||||||
@@ -208,6 +208,24 @@ class ApiClient(
|
|||||||
.retrieve().body(DistributeResultDto::class.java)
|
.retrieve().body(DistributeResultDto::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun assignVariant(token: String, payload: AssignVariantPayload): VariantAssignmentResultDto =
|
||||||
|
call {
|
||||||
|
client.post().uri("/api/v1/assign")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(payload)
|
||||||
|
.retrieve().body(VariantAssignmentResultDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLabAssignments(token: String, labId: String): List<VariantAssignmentResultDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<VariantAssignmentResultDto>>() {}
|
||||||
|
return call {
|
||||||
|
client.get().uri("/api/v1/assignments?labId={labId}", labId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve().body(type)
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
fun meAssignments(token: String): List<MeAssignmentDto> {
|
fun meAssignments(token: String): List<MeAssignmentDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<MeAssignmentDto>>() {}
|
val type = object : ParameterizedTypeReference<List<MeAssignmentDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
|
|||||||
@@ -110,6 +110,28 @@ data class LevelAssignPayload(
|
|||||||
val labLevelId: String,
|
val labLevelId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class AssignVariantPayload(
|
||||||
|
val studentId: String,
|
||||||
|
val labId: String,
|
||||||
|
val variantId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class VariantAssignmentResultDto(
|
||||||
|
val studentId: String = "",
|
||||||
|
val labId: String = "",
|
||||||
|
val variantId: String = "",
|
||||||
|
val variantNumber: String = "",
|
||||||
|
val manual: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LabLevelVariantDto(
|
||||||
|
val id: String = "",
|
||||||
|
val number: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LevelAssignmentResultDto(
|
data class LevelAssignmentResultDto(
|
||||||
val studentId: String = "",
|
val studentId: String = "",
|
||||||
@@ -129,7 +151,9 @@ data class MeAssignmentDto(
|
|||||||
val deadlineAt: String? = null,
|
val deadlineAt: String? = null,
|
||||||
val defenseAt: String? = null,
|
val defenseAt: String? = null,
|
||||||
val pointsMax: Any? = null,
|
val pointsMax: Any? = null,
|
||||||
)
|
) {
|
||||||
|
var statementHtml: String = ""
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LabDetailDto(
|
data class LabDetailDto(
|
||||||
|
|||||||
@@ -5,18 +5,47 @@ import org.springframework.stereotype.Controller
|
|||||||
import org.springframework.ui.Model
|
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.markdown.MarkdownRenderer
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.MeAssignmentDto
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/student")
|
@RequestMapping("/student")
|
||||||
class StudentController(private val api: ApiClient) {
|
class StudentController(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val markdown: MarkdownRenderer,
|
||||||
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun dashboard(session: HttpSession, model: Model): String {
|
fun dashboard(session: HttpSession, model: Model): String {
|
||||||
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
||||||
val assignments = api.meAssignments(token)
|
val assignments = api.meAssignments(token).map {
|
||||||
|
it.apply { statementHtml = markdown.render(statementMd) }
|
||||||
|
}
|
||||||
model.addAttribute("assignments", assignments)
|
model.addAttribute("assignments", assignments)
|
||||||
return "student/dashboard"
|
return "student/dashboard"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class StudentAssignmentView(
|
||||||
|
val labNumber: Int,
|
||||||
|
val labTitle: String,
|
||||||
|
val variantNumber: String,
|
||||||
|
val variantTitle: String,
|
||||||
|
val statementHtml: String,
|
||||||
|
val levelTitle: String,
|
||||||
|
val deadlineAt: String?,
|
||||||
|
val defenseAt: String?,
|
||||||
|
) {
|
||||||
|
constructor(a: MeAssignmentDto, statementHtml: String) : this(
|
||||||
|
labNumber = a.labNumber,
|
||||||
|
labTitle = a.labTitle,
|
||||||
|
variantNumber = a.variantNumber,
|
||||||
|
variantTitle = a.variantTitle,
|
||||||
|
statementHtml = statementHtml,
|
||||||
|
levelTitle = a.levelTitle,
|
||||||
|
deadlineAt = a.deadlineAt,
|
||||||
|
defenseAt = a.defenseAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -158,6 +158,28 @@ tr:last-child td { border-bottom: none; }
|
|||||||
/* Theme toggle */
|
/* Theme toggle */
|
||||||
.theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
.theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
/* Group assignment page */
|
||||||
|
.group-title { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
|
||||||
|
.inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; }
|
||||||
|
.inline-form .input-sm { width: auto; min-width: 10rem; }
|
||||||
|
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
|
||||||
|
.filter-select { width: auto; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Markdown body */
|
||||||
|
.md-body { font-size: 0.9375rem; }
|
||||||
|
.md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; }
|
||||||
|
.md-body h1 { font-size: 1.25rem; }
|
||||||
|
.md-body h2 { font-size: 1.125rem; }
|
||||||
|
.md-body p, .md-body ul, .md-body ol { margin: 0.5rem 0; }
|
||||||
|
.md-body ul, .md-body ol { padding-left: 1.5rem; }
|
||||||
|
.md-body pre { background: var(--c-bg); border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.75rem 1rem; overflow-x: auto; font-size: 0.8125rem; }
|
||||||
|
.md-body code { font-family: monospace; }
|
||||||
|
.md-body details.spoiler { border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.5rem 0.75rem; margin: 0.5rem 0; }
|
||||||
|
.md-body details.spoiler summary { cursor: pointer; font-weight: 500; color: var(--c-text-link); }
|
||||||
|
.md-body img { max-width: 100%; }
|
||||||
|
.md-body table { margin: 0.5rem 0; }
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; }
|
.empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,20 @@
|
|||||||
<head><title th:text="${lab.title} + ' — LMS'">Лабораторная</title></head>
|
<head><title th:text="${lab.title} + ' — LMS'">Лабораторная</title></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<a th:href="@{/admin/labs}" class="text-sm">← Все лабораторные</a>
|
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
|
||||||
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6">
|
||||||
<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>
|
<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>
|
||||||
|
<div class="flex gap-2">
|
||||||
<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>
|
||||||
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить (сбросить ручные)</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||||
|
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
@@ -20,24 +27,73 @@
|
|||||||
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
|
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="level : ${lab.levels}">
|
<tr th:each="level : ${lab.levels}">
|
||||||
<td th:text="${level.code}">—</td>
|
<td class="font-mono" th:text="${level.code}">—</td>
|
||||||
<td th:text="${level.title}">—</td>
|
<td th:text="${level.title}">—</td>
|
||||||
<td th:text="${#lists.size(level.variants)}">0</td>
|
<td th:text="${#lists.size(level.variants)}">0</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровни не найдены.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 mb-4">
|
||||||
<h2 class="section-title">Студенты и варианты</h2>
|
<h2 class="section-title">Студенты и варианты</h2>
|
||||||
<p class="text-sm text-muted mb-4">Нажмите «Распределить», чтобы закрепить варианты. Можно задать уровень студента.</p>
|
<label class="text-sm text-muted">Группа:
|
||||||
<div class="card card-flush">
|
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option th:each="g : ${groups}" th:value="${g.groupName}" th:text="${g.groupName}">—</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted mb-4">Уровень по умолчанию — самый простой. Селекты позволяют выдать вариант посложнее или назначить вариант вручную.</p>
|
||||||
|
|
||||||
|
<div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}">
|
||||||
|
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||||||
|
<div class="card card-flush mb-6">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Студент</th><th>Уровень</th><th>Вариант</th></tr></thead>
|
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th></tr></thead>
|
||||||
<tbody id="student-rows">
|
<tbody>
|
||||||
<!-- Filled dynamically after distribution -->
|
<tr th:each="s : ${g.students}">
|
||||||
|
<td th:text="${s.fullName}">—</td>
|
||||||
|
<td class="font-mono" th:text="${s.studentNo}">—</td>
|
||||||
|
<td>
|
||||||
|
<form class="inline-form" th:action="@{/admin/labs/{labId}/set-level(labId=${lab.id})}" method="post">
|
||||||
|
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||||
|
<select class="input input-sm" name="labLevelId">
|
||||||
|
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}"
|
||||||
|
th:selected="${level.id == s.levelId}">—</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form class="inline-form" th:action="@{/admin/labs/{labId}/assign(labId=${lab.id})}" method="post">
|
||||||
|
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||||
|
<select class="input input-sm" name="variantId">
|
||||||
|
<optgroup th:each="level : ${lab.levels}" th:label="${level.title}">
|
||||||
|
<option th:each="v : ${level.variants}" th:value="${v.id}"
|
||||||
|
th:text="|${v.number} — ${v.title}|"
|
||||||
|
th:selected="${v.id == s.variantId}">—</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
|
||||||
|
<td><span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-warn' : 'badge-ok'" th:text="${s.source}">—</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="6" class="empty">В группе нет студентов.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function filterGroups(name) {
|
||||||
|
document.querySelectorAll('.group-section').forEach(function (el) {
|
||||||
|
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -9,28 +9,23 @@
|
|||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
|
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
|
||||||
<h2 class="section-title">Импорт лабораторной</h2>
|
<h2 class="section-title">Импорт лабораторной</h2>
|
||||||
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json (старый формат).</p>
|
<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>
|
<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>
|
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-flush mt-8">
|
<div class="card card-flush mt-8">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>№</th><th>Название</th><th>Семестр</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="lab : ${labs}">
|
<tr th:each="lab : ${labs}">
|
||||||
<td th:text="${lab.number}">1</td>
|
<td th:text="${lab.number}">1</td>
|
||||||
<td th:text="${lab.title}">—</td>
|
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
|
||||||
<td class="text-muted" th:text="${lab.semesterTitle}">—</td>
|
|
||||||
<td th:text="${lab.variantCount}">0</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><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td>
|
||||||
<td class="text-center">
|
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><button class="nav-link text-sm" type="submit">Удалить</button></form></td>
|
||||||
<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>
|
||||||
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
|
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
<div class="page-header-inner">
|
<div class="page-header-inner">
|
||||||
<a th:href="@{/}" class="logo">LMS</a>
|
<a th:href="@{/}" class="logo">LMS</a>
|
||||||
<nav class="nav" th:if="${currentUser != null}">
|
<nav class="nav" th:if="${currentUser != null}">
|
||||||
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link">Панель</a>
|
|
||||||
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
|
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
|
||||||
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
|
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
|
||||||
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
||||||
|
|||||||
@@ -5,23 +5,25 @@
|
|||||||
<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 th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
|
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
|
||||||
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
|
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-flush" th:unless="${#lists.isEmpty(assignments)}">
|
|
||||||
<table>
|
<div th:each="a : ${assignments}" class="card mb-6">
|
||||||
<thead><tr><th>№</th><th>Название</th><th>Вариант</th><th>Срок сдачи</th><th>Дата защиты</th><th>Статус</th></tr></thead>
|
<div class="card-body">
|
||||||
<tbody>
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<tr th:each="a : ${assignments}">
|
<h2 class="section-title" th:text="|ЛР ${a.labNumber}. ${a.labTitle}|">ЛР</h2>
|
||||||
<td th:text="${a.labNumber}">1</td>
|
<div class="flex gap-2 items-center">
|
||||||
<td th:text="${a.labTitle}">—</td>
|
<span class="badge badge-ok">Вариант <span class="font-mono" th:text="${a.variantNumber}">—</span></span>
|
||||||
<td th:text="${a.variantNumber}">—</td>
|
<span th:if="${a.levelTitle}" class="badge badge-warn" th:text="${a.levelTitle}">—</span>
|
||||||
<td class="text-sm text-muted" th:text="${a.deadlineAt}">—</td>
|
</div>
|
||||||
<td class="text-sm text-muted" th:text="${a.defenseAt}">—</td>
|
</div>
|
||||||
<td><span class="badge badge-warn">назначено</span></td>
|
<p class="text-sm text-muted" th:if="${a.variantTitle}" th:text="${a.variantTitle}">—</p>
|
||||||
</tr>
|
<p class="text-sm text-muted" th:if="${a.deadlineAt}" th:text="|Срок сдачи: ${a.deadlineAt}|">—</p>
|
||||||
</tbody>
|
<p class="text-sm text-muted" th:if="${a.defenseAt}" th:text="|Защита: ${a.defenseAt}|">—</p>
|
||||||
</table>
|
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user