Phase 7: deployment, data import, UI hardening and security fixes

Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
  nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers

Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
  least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
  selected students (checkboxes)

Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
  from group page; temporary-password lock (no navigation until changed) and
  persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
  compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
  chapters tree+assets); duplicate lab numbers rejected; variant ordering
  and date defaults honored

Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
2026-09-14 22:01:46 +03:00
parent 08830c67f1
commit d04207302a
58 changed files with 3967 additions and 884 deletions

View File

@@ -14,6 +14,7 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)

View File

@@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -16,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import java.util.UUID
@RestController
@@ -46,6 +49,25 @@ class GroupController(private val groupService: GroupService) {
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@GetMapping("/{id}/candidates")
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
/** Зачислить существующего студента. */
@PostMapping("/{id}/students/{studentId}/enroll")
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.enroll(id, studentId)
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.unenroll(id, studentId)
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)

View File

@@ -49,6 +49,7 @@ class GroupService(
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student = students.save(
Student(
studentNo = studentNo,
@@ -57,9 +58,9 @@ class GroupService(
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
student.account = account
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
@@ -78,6 +79,61 @@ class GroupService(
return summary(groups.save(GroupEntity(name = name)))
}
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
@Transactional(readOnly = true)
fun candidates(groupId: UUID): List<StudentCandidate> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
for (e in enrollments.findAll()) {
val sid = e.student?.accountId ?: continue
val gid = e.group?.id ?: continue
val name = e.group?.name ?: continue
currentByStudent[sid] = gid to name
}
return students.findAll()
.filter { it.accountId !in memberIds }
.map { s ->
val current = s.accountId?.let { currentByStudent[it] }
StudentCandidate(
id = s.accountId!!.toString(),
fullName = s.fullName,
studentNo = s.studentNo,
currentGroupId = current?.first?.toString(),
currentGroupName = current?.second,
)
}
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}
/** Зачислить существующего студента; если он в другой группе — переводим. */
@Transactional
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudent_AccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
}
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
existing.group = group
enrollments.save(existing)
} else {
enrollments.save(Enrollment(group = group, student = student))
}
return detail(groupId)
}
/** Отчислить из группы (студент и его аккаунт сохраняются). */
@Transactional
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
return detail(groupId)
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
val parsed = parser.parse(filename, bytes)
@@ -141,6 +197,17 @@ class GroupService(
return ImportResult(group.id.toString(), group.name, added, updated)
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
taskDurationMin = group.taskDurationMin,
)
}
@Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }

View File

@@ -59,6 +59,14 @@ data class ResetPasswordResponse(
val password: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,
val studentNo: String,
val currentGroupId: String?,
val currentGroupName: String?,
)
class ExportFile(
val bytes: ByteArray,
val filename: String,

View File

@@ -35,3 +35,8 @@ management:
springdoc:
api-docs:
path: /v3/api-docs
lms:
jwt:
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}