Redesign B3 bordeaux + finish lab-page rework

- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
This commit is contained in:
2026-10-02 15:41:16 +03:00
parent 7a781fea33
commit 556fdf3b5e
67 changed files with 3019 additions and 653 deletions

View File

@@ -2,15 +2,18 @@ package ru.ysu.lms.progress
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import ru.ysu.lms.common.NotFoundException
import java.net.http.HttpClient
import java.time.Duration
@Service
class AcademicClient(
@Value("\${lms.academic.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
private val client = restClient(baseUrl)
fun getLab(
labId: String,
@@ -41,7 +44,7 @@ class AcademicClient(
class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
private val client = restClient(baseUrl)
fun getGroup(
groupId: String,
@@ -65,3 +68,20 @@ class IdentityClient(
.body(type) ?: emptyList()
}
}
/** Таймауты обязательны: зависший downstream держит JDBC-соединение из пула и вешает сервис. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 30L
private fun restClient(baseUrl: String): RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
).build()

View File

@@ -102,7 +102,8 @@ class DefenseService(
sessions.save(s)
}
@Transactional(readOnly = true)
/** Не readOnly: внутри переносим истёкшие защиты в ready — изменения должны сохраниться. */
@Transactional
fun listForLab(labId: UUID): List<DefenseDto> {
syncTimeouts()
return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) }

View File

@@ -180,3 +180,18 @@ data class PublicationSetRequest(
val labId: String,
val studentIds: List<String> = emptyList(),
)
/** Агрегированные данные одной лабораторной по всем студентам группы (для страницы группы одним запросом). */
data class LabDataResponse(
val labId: String,
val assignments: List<VariantAssignmentResponse> = emptyList(),
val levels: List<LevelAssignmentResponse> = emptyList(),
val submissions: List<SubmissionDto> = emptyList(),
val defenses: List<DefenseDto> = emptyList(),
val publication: PublicationState = PublicationState(),
)
data class GroupLabDataResponse(
val groupId: String,
val labs: List<LabDataResponse> = emptyList(),
)

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress
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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class GroupLabDataController(
private val service: GroupLabDataService,
) {
/** Данные всех студентов группы по выбранным лабораторным — только для админа. */
@GetMapping("/api/v1/assignments/group/{groupId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun groupLabData(
@PathVariable groupId: UUID,
@RequestParam("labId") labIds: List<UUID>,
@AuthenticationPrincipal jwt: Jwt,
): GroupLabDataResponse = service.labData(groupId, labIds, jwt.tokenValue)
}

View File

@@ -0,0 +1,46 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import java.util.UUID
/**
* Агрегатор данных страницы группы: назначения, уровни, последние сдачи,
* защиты и состояние публикации по каждой лабораторной. Заменяет ~4N запросов
* BFF к сервисам одним вызовом. Без своей транзакции: внутренние сервисы
* управляют своими (внутри пишущий syncTimeouts — readOnly снаружи нельзя).
*/
@Service
class GroupLabDataService(
private val progress: ProgressService,
private val submissions: SubmissionService,
private val defenses: DefenseService,
private val publications: GradePublicationService,
) {
fun labData(
groupId: UUID,
labIds: List<UUID>,
token: String,
): GroupLabDataResponse =
GroupLabDataResponse(
groupId = groupId.toString(),
labs =
labIds.map { labId ->
LabDataResponse(
labId = labId.toString(),
assignments = progress.assignmentsForLabEnriched(labId, token),
levels =
progress.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse(
studentId = sll.studentId.toString(),
labId = sll.labId.toString(),
labLevelId = sll.labLevelId.toString(),
manual = sll.manual,
)
},
submissions = submissions.latestPerStudent(labId),
defenses = defenses.listForLab(labId),
publication = publications.state(groupId, labId),
)
},
)
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.progress
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@@ -8,6 +9,7 @@ import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.io.IOException
import java.nio.file.Files
import java.time.Instant
import java.util.UUID
@@ -28,8 +30,22 @@ class SubmissionService(
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
// Сначала запись в БД: unique-констрейнт (student, lab, seq) гасит двойную отправку,
// и проигравшая попытка не трогает файлы на диске (иначе перезаписала бы предыдущую сдачу).
val entity =
try {
submissions.saveAndFlush(Submission(studentId = studentId, labId = labId, seq = seq))
} catch (ignore: DataIntegrityViolationException) {
throw BadRequestException("Эта попытка уже отправлена — обновите страницу и попробуйте ещё раз")
}
val stored =
try {
storage.store(studentId, labId, seq, payload)
} catch (ignore: IOException) {
// строка откатится вместе с транзакцией; убираем недописанные файлы
runCatching { storage.deleteAttemptDir(studentId, labId, seq) }
throw BadRequestException("Не удалось сохранить файлы решения, попробуйте ещё раз")
}
return toDto(entity, stored.files)
}

View File

@@ -37,12 +37,32 @@ class SubmissionStorage(
val names = mutableListOf<String>()
for ((rawName, bytes) in files) {
val safe = sanitize(rawName)
Files.write(dir.resolve(safe), bytes)
val target = dir.resolve(safe).normalize()
require(target.startsWith(dir)) { "path escape blocked" }
Files.write(target, bytes)
names.add(safe)
}
return StoredSubmission(dir, names)
}
/** Страховка после сбоя записи: недописанная попытка не должна оставлять мусор на диске. */
fun deleteAttemptDir(
studentId: UUID,
labId: UUID,
seq: Int,
) {
val dir = dirFor(studentId, labId, seq).normalize()
require(dir.startsWith(rootDir) && dir != rootDir) { "path escape blocked" }
if (!Files.exists(dir)) return
Files
.walk(dir)
.use { walk ->
walk
.sorted(Comparator.reverseOrder())
.forEach { p -> runCatching { Files.delete(p) } }
}
}
/** Собирает zip из сохранённых файлов сдачи, сохраняя реальное время изменения файлов. */
fun zipOf(storedDir: Path): ByteArray {
val out = ByteArrayOutputStream()

View File

@@ -79,4 +79,46 @@ class ProgressDistributionIntegrationTest {
assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2)
assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB)
}
@Autowired
private lateinit var groupLabData: GroupLabDataService
@Test
fun `group lab data aggregates per-lab data in one call`() {
val labId = UUID.randomUUID()
val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString()
val variantA = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo(
id = labId.toString(),
levels =
listOf(
LevelInfo(
id = "lvl",
code = "basic",
title = "Основная",
variants = listOf(VariantInfo(id = variantA, number = "1")),
),
),
),
)
service.distribute(DistributeRequest(labIds = listOf(labId.toString()), groupIds = listOf("g1")), "t")
val data = groupLabData.labData(UUID.randomUUID(), listOf(labId), "t")
assertThat(data.labs).hasSize(1)
val lab = data.labs.first()
assertThat(lab.labId).isEqualTo(labId.toString())
assertThat(lab.assignments).hasSize(2)
assertThat(lab.assignments.all { it.variantNumber == "1" }).isTrue()
assertThat(lab.defenses).isEmpty()
assertThat(lab.submissions).isEmpty()
assertThat(lab.publication.all).isFalse()
}
}

View File

@@ -6,6 +6,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission
@@ -27,7 +28,7 @@ class SubmissionServiceTest {
var saved: Submission? = null
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
every { repo.save(any()) } answers {
every { repo.saveAndFlush(any()) } answers {
firstArg<Submission>().also { saved = it }
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
@@ -73,6 +74,17 @@ class SubmissionServiceTest {
assertThat(dto.files).containsExactly("project.zip")
}
@Test
fun `duplicate concurrent attempt is rejected as bad request`() {
val (service, repo) = service()
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
every { repo.saveAndFlush(any()) } throws DataIntegrityViolationException("duplicate key")
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("уже отправлена")
}
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
val result = linkedMapOf<String, ByteArray>()
ZipInputStream(bytes.inputStream()).use { zin ->