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:
@@ -15,6 +15,11 @@ import org.springframework.web.multipart.MultipartFile
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
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 java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
@@ -118,6 +123,94 @@ class AdminController(private val api: ApiClient) {
|
||||
.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 =
|
||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||
|
||||
@@ -143,6 +143,80 @@ class ApiClient(
|
||||
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 {
|
||||
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||
}
|
||||
|
||||
@@ -71,6 +71,92 @@ data class GroupDetailDto(
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
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(
|
||||
val bytes: ByteArray,
|
||||
val filename: String,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package ru.ysu.lms.web.student
|
||||
|
||||
import jakarta.servlet.http.HttpSession
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/student")
|
||||
class StudentController {
|
||||
class StudentController(private val api: ApiClient) {
|
||||
|
||||
@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>
|
||||
<div id="content">
|
||||
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
|
||||
<p class="text-sm text-muted mb-6">Здесь появятся лабораторные работы, условия вашего варианта и материалы занятий.</p>
|
||||
<div class="card card-body">
|
||||
<p>Разделы в разработке:</p>
|
||||
<ul class="text-sm text-muted mt-2 list-pad">
|
||||
<li>список лабораторных и сроки сдачи;</li>
|
||||
<li>условие закреплённого за вами варианта;</li>
|
||||
<li>статьи и материалы занятий;</li>
|
||||
<li>отправка решений.</li>
|
||||
</ul>
|
||||
<p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
|
||||
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
|
||||
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
|
||||
</div>
|
||||
<div class="card card-flush" th:unless="${#lists.isEmpty(assignments)}">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Вариант</th><th>Срок сдачи</th><th>Дата защиты</th><th>Статус</th></tr></thead>
|
||||
<tbody>
|
||||
<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>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user