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:
@@ -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>,
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -18,16 +18,3 @@ class VariantAssignment(
|
|||||||
@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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -28,3 +28,9 @@ management:
|
|||||||
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}
|
||||||
@@ -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, "Сессия истекла, войдите заново")
|
||||||
|
|||||||
@@ -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*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
43
web/src/main/resources/templates/admin/lab.html
Normal file
43
web/src/main/resources/templates/admin/lab.html
Normal 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>
|
||||||
39
web/src/main/resources/templates/admin/labs.html
Normal file
39
web/src/main/resources/templates/admin/labs.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user