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:
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user