fix: закрыть P0/P1 расхождения с целевой моделью

- академический отпуск: readOnly, запрет submit/start/finish защиты
- черновики лабораторных скрыты от студентов в списке/деталях/по UUID
- удалено действие «Убрать из группы» из UI и API
- импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта
- глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории
- LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0
- backup: безопасный парсинг .env, verify-latest, кавычки cron
- robots/noindex, комментарии без автора, профиль без черновиков
- docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
2026-10-29 18:58:21 +03:00
parent 9cb4d0d263
commit 9412d87fed
63 changed files with 1281 additions and 162 deletions

View File

@@ -32,4 +32,4 @@ LMS_SESSION_TIMEOUT=7d
LMS_SESSION_ABSOLUTE_TIMEOUT=30d LMS_SESSION_ABSOLUTE_TIMEOUT=30d
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой. # Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
LMS_REFRESH_TOKEN_RETENTION=30d LMS_REFRESH_TOKEN_RETENTION=30d
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * * LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'

View File

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **51 claim, 348 тестов, 18/18 browser E2E**. Baseline: **57 claims, 366 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)

View File

@@ -65,7 +65,7 @@ LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и о
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * * LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
``` ```
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,

View File

@@ -35,6 +35,7 @@ usage() {
usage: usage:
deploy/backup.sh backup [--if-running] deploy/backup.sh backup [--if-running]
deploy/backup.sh list deploy/backup.sh list
deploy/backup.sh verify-latest
deploy/backup.sh verify <backup-dir> deploy/backup.sh verify <backup-dir>
deploy/backup.sh restore <backup-dir> deploy/backup.sh restore <backup-dir>
@@ -50,13 +51,25 @@ require_docker() {
load_env() { load_env() {
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE" [[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
set -a # Не source-им .env.prod: значения с пробелами (например, cron) иначе
# shellcheck disable=SC1090 # исполняются как команды, а произвольный shell-код из файла опасен.
source "$ENV_FILE" local key value line
set +a while IFS= read -r line; do
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\"}"; value="${value%\"}"
value="${value#\'\'}"; value="${value%\'\'}"
case "$key" in
LMS_DB_NAME | LMS_DB_USER | LMS_DB_PASSWORD | LMS_WEB_PORT | \
LMS_BACKUP_DIR | LMS_BACKUP_KEEP_DAYS | LMS_COMPOSE_PROJECT)
export "$key=$value"
;;
esac
done <"$ENV_FILE"
: "${LMS_DB_NAME:=lms}" : "${LMS_DB_NAME:=lms}"
: "${LMS_DB_USER:=lms}" : "${LMS_DB_USER:=lms}"
# Переменные из .env.prod должны применяться и к путям backup.
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}" BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}" KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}" PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
@@ -164,6 +177,24 @@ list_backups() {
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
} }
latest_backup() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
[[ -d "$BACKUP_ROOT" ]] || return 0
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -print 2>/dev/null | sort | tail -n 1 || true
}
verify_latest_backup() {
local dir
dir="$(latest_backup)"
if [[ -z "$dir" ]]; then
info "Backup ещё не создавался — проверять нечего"
return 0
fi
verify_backup "$dir"
}
verify_backup() { verify_backup() {
local dir="${1:-}" local dir="${1:-}"
[[ -n "$dir" ]] || usage [[ -n "$dir" ]] || usage
@@ -248,6 +279,9 @@ case "${1:-backup}" in
list) list)
list_backups list_backups
;; ;;
verify-latest)
verify_latest_backup
;;
verify) verify)
verify_backup "${2:-}" verify_backup "${2:-}"
;; ;;

View File

@@ -32,6 +32,7 @@ case "$cmd" in
up) up)
echo ">> Резервная копия перед обновлением (обязательно)..." echo ">> Резервная копия перед обновлением (обязательно)..."
./deploy/backup.sh backup --if-running ./deploy/backup.sh backup --if-running
./deploy/backup.sh verify-latest
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..." echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
$COMPOSE build $COMPOSE build
echo ">> Запуск сервисов..." echo ">> Запуск сервисов..."

View File

@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events` ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**348**, 0 failures), upgrade миграций с master-схемы тестов/линтеров (**366**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица, (identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims
(25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт: (27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md). [16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6 ### 9.1. Известные пробелы после этапа 6

View File

@@ -71,9 +71,9 @@
| web | 108 | 0 | 0 | 0 | | web | 108 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 | | gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **348** | **0** | **0** | **0** | | **Итого на момент этапа 6** | **366** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **348 тестов, 0 failures** После закрытия рисков актуальный LDD-baseline — **366 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация (см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже. gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки ### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 348 тестов, 0 failures) Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс». negative-тестами «свой/чужой курс».
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline ## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**51 claims, 25 P0 / 26 P1**), LDD-контур: реестр `docs/evidence/claims.json` (**57 claims, 27 P0 / 30 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow, gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат: mutation testing и браузерный Playwright E2E. Результат:
**51/51 claims proven, P0 gaps = 0, 348 тестов, 0 failures**; mutation strength **57/57 claims proven, P0 gaps = 0, 366 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit); `CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.

View File

@@ -116,8 +116,8 @@
### 1.8. LDD и проверки ### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`. - Реестр: `docs/evidence/claims.json`.
- **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**. - **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**.
- **348 автотестов**, 0 failures/errors/skipped. - **366 автотестов**, 0 failures/errors/skipped.
- Mutation testing: - Mutation testing:
- `CourseAccessService` — 75% test strength; - `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%. - `PromotionPlanner` — 69%.
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
``` ```
Последний подтверждённый результат: Последний подтверждённый результат:
- `348` тестов, 0 failures; - `366` тестов, 0 failures;
- `51/51` claims; - `57/57` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS; - `ldd-release.sh` — PASS;
- smoke — PASS; - smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit); - browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
`feature/groups-courses`. `feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`. `docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 348 тестов 3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов
и 51/51 claims. и 57 claims, 57/57 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`. 4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и

141
docs/18-goal-audit.md Normal file
View File

@@ -0,0 +1,141 @@
# 18. Сверка цели и фактического состояния
**Дата:** 2026-10-29.
**Ветка:** `feature/groups-courses`.
**Источники цели:** `07-groups-courses.md`, `08-groups-courses-plan.md`,
`09-teacher-student-profile.md`, `16-stage6-report.md`, `17-handoff.md`.
Документ фиксирует, где приложение уже соответствует целевой модели, где
остались отличия и какие решения приняты по спорным местам. Это не отчёт
«всё готово»: статус `partial` означает реальный незакрытый пункт.
## 1. Сводка
| Область | Статус | Что осталось |
|---|---|---|
| Роли, группы-когорты, статусы, история | partial | Финальные `NOT NULL`/полная уникальность когорты; группа не восстанавливается из архива; история членства не показана в UI |
| Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service |
| Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID |
| «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив |
| Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями |
| Курсы, преподаватели, группы курса | implemented | — |
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
| Профиль студента | partial | Браузерный URL использует UUID вместо `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» |
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
| Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет |
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе |
| LDD | implemented | 57 claims (27 P0 / 30 P1), 57/57 proven после release; `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 |
## 2. Что исправлено в этой итерации
1. **Read-only академотпуск.**
- `AuthResponse`/`MeResponse` содержат `readOnly`; web-сессия хранит флаг.
- `StudentWriteGuard` в progress-service блокирует submit, start и finish защиты.
- UI скрывает сдачу/защиту и показывает объяснение.
- Тесты: `StudentLifecycleIntegrationTest#academic leave login is read only`,
`SubmissionServiceTest#academic leave student cannot submit`,
`DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense`.
2. **Черновики лабораторных скрыты от студентов.**
- `CourseAccessService.requireLabAccess` отдаёт `404` студенту для черновика.
- `GET /api/v1/labs` и `/labs/details` фильтруют черновики для студента.
- Тесты: `LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id`,
`CourseAccessServiceTest#student cannot access a draft lab of own course`.
3. **Удалено действие «Убрать из группы».**
- Удалены endpoint, web-маршрут, API-клиент, кнопка и сервисный метод.
- Тесты шаблонов проверяют отсутствие действия.
4. **Импорт больше не воскрешает неактивных студентов без флага.**
- `restoreInactiveStatuses` (по умолчанию `false`), `ImportResult.skipped`.
- Без флага сохраняются `EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE`, строка попадает в отчёт.
- С флагом студент активируется, архивный аккаунт разблокируется, пишется `IMPORT`.
- Тесты: `GroupServiceIntegrationTest#import does not resurrect...`,
`#import restores expelled student...`, `#import preserves academic leave...`,
`#import still updates an active student`, шаблонные тесты отчёта.
5. **Глобальные материалы `STUDENTS`/`TEACHERS`.**
- Дерево и статьи фильтруются по роли JWT; аноним — только `ALL`.
- Админ выбирает аудиторию при создании и может изменить её.
- Тесты: `GlobalAudienceAccessIntegrationTest` (5 сценариев).
6. **LDD gate на чистом checkout.**
- `LDD-AUTH-005` теперь P0 с unit-тестом gateway; полный refresh/handshake вынесен в P1 `LDD-AUTH-008` (runtime).
- `evidenceCheck` без `build/evidence` проходит по P0; runtime P1 закрываются в release.
7. **Backup/env.**
- `LMS_REFRESH_TOKEN_CLEANUP_CRON` в примерах закавычен.
- `backup.sh` больше не `source`-ит `.env.prod` (не исполняет cron как команду).
- `deploy.sh up` после backup вызывает `verify-latest`.
8. **Robots/noindex.**
- `robots.txt`; `X-Robots-Tag: noindex, nofollow` для admin/teacher/student/login/api/курсовых материалов.
9. **Профиль.**
- Старые комментарии без автора показывают «автор не указан».
- Студенческий профиль не показывает черновики лабораторных.
## 3. Остаточные отличия от цели
### P1
- **Импорт: ошибки БД.** Ошибки lookup/конфликта всё ещё могут выйти одним
flash-сообщением без номера строки; нужен row-level отчёт и для них.
- **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null`
и не создаёт план за предыдущий год; после долгого простоя в январе
пропущенный август не догоняется.
- **Профиль: контракт.** Браузерные маршруты используют UUID курса, а docs/09
обещает `courseNo`; часть полей `variant/level/grade` отличается от контракта.
Нужно либо привести код к контракту, либо официально обновить контракт.
- **Backup restore на staging** не подтверждён артефактом/логом.
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
### P2
- Когорта: поля всё ещё nullable, уникальность частичная; нужен preflight и
финальный `SET NOT NULL`/полный unique index после ручного разбора legacy.
- Нет восстановления группы из архива (сейчас архив односторонний).
- Нет пагинации списков материалов и групп (аудит и профиль уже лучше).
- Попытки сдачи в профиле не ограничены/не пагинированы.
- Сдачи в ведомости запрашиваются по выбранной лабораторной, а не пакетно.
- Нет визуальной регрессии и полного мобильного набора E2E.
- Нет тестов на audit-события `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL`.
- Mutation testing не 100%, нагрузочный smoke не SLA.
### Операционные
- Инвентаризация legacy-активов на реальном хранилище.
- Установка/проверка daily backup timer на сервере.
- Первый push-MR и tag pipeline в Woodpecker.
- Restore на staging.
## 4. Принятые решения
- `ACADEMIC_LEAVE`: блокировать только сдачу и защиты; чтение и скачивание
своих файлов разрешены.
- «Убрать из группы»: действие удалено полностью.
- Импорт терминальных/академических статусов: только по явному флагу
`restoreInactiveStatuses`.
- Глобальные материалы: `ALL/STUDENTS/TEACHERS` поддерживаются; `STUDENTS`
видит студент, `TEACHERS` — преподаватель, аноним — только `ALL`.
- «Контингент» остаётся вне scope.
## 5. LDD
- Реестр: `docs/evidence/claims.json`, **57 claims** (27 P0 / 30 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **57/57 claims**, 366 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с.
## 6. Следующий порядок работ
1. Закрыть P1: row-level ошибки импорта, догон повышения, контракт профиля,
staging restore, первый CI pipeline.
2. Закрыть P2: финальная миграция когорты, unarchive, пагинация, audit-тесты,
визуальная регрессия.
3. Прогнать полный release и обновить `evidence/baseline-*`.

View File

@@ -25,8 +25,9 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы | | [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы |
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения | | [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 51 claims, 348 тестов, upgrade, runtime/scale, mutation и browser E2E | | [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 57 claims, 366 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе ## Кратко о системе

View File

@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков: рисков:
- **348 автоматических тестов**, 0 failures/errors; - **366 автоматических тестов**, 0 failures/errors;
- **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы, - **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит, (включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,

View File

@@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew test --rerun-tasks ./gradlew test --rerun-tasks
``` ```
Результат: **348 тестов, 0 failures, 0 errors, 0 skipped**. Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные. `ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate ### 2.2. LDD gate
@@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew evidenceCheck ./gradlew evidenceCheck
``` ```
Результат: **51/51 claims proven, P0 gaps = 0**. Результат: **57/57 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`. Отчёт: `build/reports/evidence/index.md`.
Gate устроен так: Gate устроен так:
@@ -306,8 +306,8 @@ optional json fields`.
## 5. Вывод ## 5. Вывод
Текущая функциональность прошла LDD-контур: **51/51 claims proven, P0 gaps = 0**, Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**,
**348 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и **366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
масштабный load зелёные. Это достаточное основание продолжать доработки, но не масштабный load зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы». «формальное доказательство всей системы».

View File

@@ -43,11 +43,11 @@
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева", "oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
"evidence": [ "evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()", "ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#public tree contains only global chapters with ALL audience()", "ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous tree contains only global chapters with ALL audience()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()", "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()" "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
], ],
"limitations": "Legacy-активы без article_assets остаются публичными и вынесены в отдельный риск." "limitations": "Legacy-активы без article_assets закрыты отдельными claims LDD-ASSET-002/003."
}, },
{ {
"id": "LDD-ACCESS-004", "id": "LDD-ACCESS-004",
@@ -659,16 +659,18 @@
}, },
{ {
"id": "LDD-AUTH-005", "id": "LDD-AUTH-005",
"title": "Refresh/logout работают через реальный gateway", "title": "Gateway пропускает refresh/logout без access-JWT",
"criticality": "P0", "criticality": "P0",
"status": "active", "status": "active",
"requirement": "docs/16-stage6-report.md, §4", "requirement": "docs/16-stage6-report.md, §4",
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.", "claim": "Gateway не требует access-JWT для /api/v1/auth/refresh и /api/v1/auth/logout; защищённый маршрут без токена получает 401. Полный цикл refresh/logout через реальный gateway проверяется отдельным P1 runtime-claim.",
"oracle": "runtime.auth_refresh", "oracle": "JwtAuthenticationFilterTest: public paths пропущены, protected path без токена — 401",
"runtime": [ "runtime": [],
"runtime.auth_refresh" "limitations": "Unit-тест проверяет маршрутизацию gateway; полный обмен пары и отзыв refresh — LDD-AUTH-008.",
], "evidence": [
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke." "ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()",
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()"
]
}, },
{ {
"id": "LDD-AUTH-006", "id": "LDD-AUTH-006",
@@ -747,6 +749,97 @@
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()" "ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
], ],
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует." "limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
},
{
"id": "LDD-AUTH-008",
"title": "Refresh/logout через реальный gateway",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
"oracle": "runtime.auth_refresh",
"runtime": [
"runtime.auth_refresh"
],
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
},
{
"id": "LDD-ACCESS-010",
"title": "Черновики лабораторных скрыты от студентов",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Студент не видит черновик лабораторной ни в списке, ни в пакетных деталях, ни по прямому UUID; преподаватель и админ видят черновик.",
"oracle": "HTTP 200/404 и фильтрация списков",
"evidence": [
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id()",
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access a draft lab of own course()"
],
"limitations": "Проверены academic API и список; отдельный тест прогресса на сдачу черновика не добавлялся, так как academic отдаёт 404."
},
{
"id": "LDD-LIFE-004",
"title": "Академический отпуск: вход только для чтения",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.3",
"claim": "Студент в ACADEMIC_LEAVE может войти и читать, но не может сдавать лабораторные и начинать/завершать защиту.",
"oracle": "readOnly в login/me, ForbiddenException на submit/start/finish",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave login is read only()",
"ru.ysu.lms.progress.SubmissionServiceTest#academic leave student cannot submit()",
"ru.ysu.lms.progress.DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense()"
],
"limitations": "Запрет проверяется на progress-service; UI скрывает кнопки, но не является единственной защитой."
},
{
"id": "LDD-LIFE-005",
"title": "Действие «Убрать из группы» удалено",
"criticality": "P1",
"status": "active",
"requirement": "docs/14-stage4-report.md, §3",
"claim": "В админском UI и API больше нет отдельного действия unenroll; доступны только перевод, академ, отчисление, архив и восстановление.",
"oracle": "Отсутствие маршрута/кнопки/метода и отрицательные проверки шаблона",
"evidence": [
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders group lifecycle buttons by student status()",
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()"
],
"limitations": "Удалённый API-маршрут не проверяется отдельным HTTP-тестом; отсутствие метода компилятор проверяет по коду."
},
{
"id": "LDD-IMPORT-002",
"title": "Импорт не воскрешает неактивных студентов без флага",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Повторный импорт сохраняет EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE без явного флага restoreInactiveStatuses и сообщает строку в skipped; с флагом активирует и зачисляет.",
"oracle": "Статус/зачисление/ImportResult.skipped",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import does not resurrect expelled student without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import restores expelled student with explicit flag and records import history()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import preserves academic leave without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import still updates an active student()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import form requires explicit restoration flag()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import result shows skipped inactive rows()"
],
"limitations": "Флаг восстанавливает статус студента; архивный аккаунт может потребовать отдельного lifecycle-восстановления."
},
{
"id": "LDD-CONTENT-003",
"title": "Глобальные материалы STUDENTS/TEACHERS",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Аноним видит только ALL; студент — ALL+STUDENTS; преподаватель — ALL+TEACHERS; прямой доступ к чужой аудитории даёт 404; админ может создать/сменить аудиторию.",
"oracle": "Фильтрация дерева/статей по JWT-роли и authoring audience",
"evidence": [
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global tree is filtered by role audience()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global article access follows audience and role()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#authoring creates requested audience and rejects invalid()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#changing audience immediately changes access()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
],
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
} }
] ]
} }

View File

@@ -1,6 +1,35 @@
package ru.ysu.lms.academic.content package ru.ysu.lms.academic.content
/** Аудитория глобальной главы, доступной без авторизации. */ import ru.ysu.lms.common.BadRequestException
/** Аудитории глобальных глав; ALL доступна без авторизации. */
const val AUDIENCE_ALL = "ALL" const val AUDIENCE_ALL = "ALL"
const val AUDIENCE_STUDENTS = "STUDENTS" const val AUDIENCE_STUDENTS = "STUDENTS"
const val AUDIENCE_TEACHERS = "TEACHERS" const val AUDIENCE_TEACHERS = "TEACHERS"
/** Допустимые аудитории глобальной главы. */
val CONTENT_AUDIENCES: Set<String> = setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS, AUDIENCE_TEACHERS)
/**
* Аудитории глобальных глав, доступные роли. Аноним видит только ALL,
* студент — ALL и STUDENTS, преподаватель — ALL и TEACHERS, администратор — всё.
*/
fun audiencesForRole(role: String?): Set<String> =
when (role) {
ROLE_ADMIN -> CONTENT_AUDIENCES
ROLE_STUDENT -> setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS)
ROLE_TEACHER -> setOf(AUDIENCE_ALL, AUDIENCE_TEACHERS)
else -> setOf(AUDIENCE_ALL)
}
/** Нормализует и проверяет аудиторию; `null` остаётся `null`. */
fun normalizeAudience(audience: String?): String? {
if (audience == null) return null
val normalized = audience.trim().uppercase()
if (normalized !in CONTENT_AUDIENCES) throw BadRequestException("Недопустимая аудитория: $audience")
return normalized
}
private const val ROLE_ADMIN = "ADMIN"
private const val ROLE_STUDENT = "STUDENT"
private const val ROLE_TEACHER = "TEACHER"

View File

@@ -61,17 +61,19 @@ class ContentAuthoringService(
} }
/** /**
* Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug * Глобальная глава (`courseId == null`) получает запрошенную аудиторию
* среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию * (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
* и уникальный slug внутри курса. * получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
*/ */
@Transactional @Transactional
fun createChapter( fun createChapter(
title: String, title: String,
slug: String?, slug: String?,
courseId: UUID? = null, courseId: UUID? = null,
audience: String? = null,
): ChapterTreeNode { ): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val requestedAudience = normalizeAudience(audience)
val scope = val scope =
if (courseId == null) { if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc() chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
@@ -88,7 +90,7 @@ class ContentAuthoringService(
title = title.trim(), title = title.trim(),
slug = cleanSlug, slug = cleanSlug,
position = maxPos + 1, position = maxPos + 1,
audience = if (courseId == null) AUDIENCE_ALL else null, audience = if (courseId == null) requestedAudience ?: AUDIENCE_ALL else null,
courseId = courseId, courseId = courseId,
), ),
) )
@@ -98,10 +100,23 @@ class ContentAuthoringService(
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
audience = ch.audience,
sections = emptyList(), sections = emptyList(),
) )
} }
/** Меняет аудиторию только у глобальной главы; у курсовой её нет. */
@Transactional
fun updateChapterAudience(
chapterId: UUID,
audience: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (ch.courseId != null) throw BadRequestException("Аудитория задаётся только для глобальных глав")
ch.audience = normalizeAudience(audience)
chapters.save(ch)
}
@Transactional @Transactional
fun renameChapter( fun renameChapter(
chapterId: UUID, chapterId: UUID,

View File

@@ -34,6 +34,7 @@ data class ChapterTreeNode(
val slug: String, val slug: String,
val position: Int, val position: Int,
val published: Boolean = true, val published: Boolean = true,
val audience: String? = null,
val sections: List<SectionTreeNode>, val sections: List<SectionTreeNode>,
) )
@@ -115,19 +116,28 @@ class ContentService(
private val importer: ContentImporter, private val importer: ContentImporter,
private val assetStorage: AssetStorage, private val assetStorage: AssetStorage,
) { ) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */ /**
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
* администратор — все аудитории. Курсовые главы здесь не показываются.
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree = fun tree(
buildTree( includeHidden: Boolean = false,
role: String? = null,
): ContentTree {
val allowedAudiences = audiencesForRole(role)
return buildTree(
chapters chapters
.findAllByOrderByPositionAscIdAsc() .findAllByOrderByPositionAscIdAsc()
.filter { .filter {
it.courseId == null && it.courseId == null &&
it.audience == AUDIENCE_ALL && it.audience in allowedAudiences &&
(includeHidden || it.published) (includeHidden || it.published)
}, },
includeHidden = includeHidden, includeHidden = includeHidden,
) )
}
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */ /** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -150,9 +160,10 @@ class ContentService(
sectionSlug: String, sectionSlug: String,
articleSlug: String, articleSlug: String,
includeHidden: Boolean = false, includeHidden: Boolean = false,
role: String? = null,
): ContentArticleView { ): ContentArticleView {
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена") val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
requirePublicChapter(ch, includeHidden) requirePublicChapter(ch, includeHidden, role)
return articleView(ch, sectionSlug, articleSlug, includeHidden) return articleView(ch, sectionSlug, articleSlug, includeHidden)
} }
@@ -255,9 +266,10 @@ class ContentService(
private fun requirePublicChapter( private fun requirePublicChapter(
ch: ContentChapter, ch: ContentChapter,
includeHidden: Boolean, includeHidden: Boolean,
role: String?,
) { ) {
requireVisible(ch.published, includeHidden, "Глава скрыта") requireVisible(ch.published, includeHidden, "Глава скрыта")
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна") if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
} }
private fun buildTree( private fun buildTree(
@@ -284,6 +296,7 @@ class ContentService(
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
audience = ch.audience,
sections = sections =
(sectionList[ch.id] ?: emptyList()).map { sec -> (sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode( SectionTreeNode(

View File

@@ -60,6 +60,11 @@ class ContentAdminController(
data class ChapterCreatePayload( data class ChapterCreatePayload(
val title: String, val title: String,
val slug: String? = null, val slug: String? = null,
val audience: String? = null,
)
data class ChapterAudiencePayload(
val audience: String,
) )
data class SectionCreatePayload( data class SectionCreatePayload(
@@ -159,13 +164,29 @@ class ContentAdminController(
fun createChapter( fun createChapter(
@RequestBody body: ChapterCreatePayload, @RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug).also { ) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
audit.record( audit.record(
jwt.subject, jwt.subject,
"CONTENT_CHAPTER_CREATE", "CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER, ENTITY_CHAPTER,
it.id, it.id,
details = "title=${body.title}", details = "title=${body.title}, audience=${it.audience}",
)
}
@PutMapping("/chapters/{id}/audience")
fun chapterAudience(
@PathVariable id: UUID,
@RequestBody body: ChapterAudiencePayload,
@AuthenticationPrincipal jwt: Jwt,
) {
authoring.updateChapterAudience(id, body.audience)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_AUDIENCE",
ENTITY_CHAPTER,
id.toString(),
details = "audience=${body.audience}",
) )
} }

View File

@@ -25,6 +25,8 @@ import java.util.UUID
* Публичное чтение учебных материалов. * Публичное чтение учебных материалов.
* *
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации. * `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
* С JWT дополнительно открываются STUDENTS для студента и TEACHERS для преподавателя;
* администратор видит все аудитории. Курсовые главы отдаются только курсовыми эндпоинтами.
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default): * Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
* их может скачать только админ для очистки. Привязанные активы наследуют * их может скачать только админ для очистки. Привязанные активы наследуют
* видимость статьи, раздела и главы. * видимость статьи, раздела и главы.
@@ -37,14 +39,20 @@ class ContentPublicController(
private val accessService: CourseAccessService, private val accessService: CourseAccessService,
) { ) {
@GetMapping("/tree") @GetMapping("/tree")
fun tree() = contentService.tree() fun tree(
@AuthenticationPrincipal jwt: Jwt?,
) = contentService.tree(role = jwt?.getClaimAsString("role"))
@GetMapping("/{chapter}/{section}/{article}") @GetMapping("/{chapter}/{section}/{article}")
fun article( fun article(
@PathVariable chapter: String, @PathVariable chapter: String,
@PathVariable section: String, @PathVariable section: String,
@PathVariable article: String, @PathVariable article: String,
): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto() @AuthenticationPrincipal jwt: Jwt?,
): ContentArticleViewDto =
contentService
.findArticle(chapter, section, article, role = jwt?.getClaimAsString("role"))
.toDto()
data class ContentArticleViewDto( data class ContentArticleViewDto(
val id: String, val id: String,

View File

@@ -46,12 +46,13 @@ class LabController(
@RequestParam(required = false) courseId: UUID?, @RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): List<LabSummary> { ): List<LabSummary> {
val student = accessService.isStudent(jwt)
if (courseId != null) { if (courseId != null) {
accessService.requireCourseAccess(courseId, jwt) accessService.requireCourseAccess(courseId, jwt)
return labService.list(courseId) return labService.list(courseId).filter { !student || it.status == "published" }
} }
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list() val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
return labService.list(accessible) return labService.list(accessible).filter { !student || it.status == "published" }
} }
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */ /** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@@ -61,7 +62,8 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): List<LabDetail> { ): List<LabDetail> {
accessService.requireCourseAccess(courseId, jwt) accessService.requireCourseAccess(courseId, jwt)
return labService.details(courseId) val details = labService.details(courseId)
return if (accessService.isStudent(jwt)) details.filter { it.status == "published" } else details
} }
@GetMapping("/{id}") @GetMapping("/{id}")

View File

@@ -67,6 +67,10 @@ class CourseAccessService(
jwt: Jwt, jwt: Jwt,
) { ) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (role(jwt) == ROLE_STUDENT && lab.status != "published") {
// Черновик не существует для студента: не раскрываем его даже прямым UUID.
throw NotFoundException("Лабораторная не найдена")
}
val courseId = lab.course?.id val courseId = lab.course?.id
if (courseId == null) { if (courseId == null) {
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна") if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
@@ -84,6 +88,8 @@ class CourseAccessService(
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
fun isStudent(jwt: Jwt): Boolean = role(jwt) == ROLE_STUDENT
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> { private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
if (courseIds.isEmpty()) return emptySet() if (courseIds.isEmpty()) return emptySet()
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id } return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }

View File

@@ -93,7 +93,7 @@ class CourseContentIntegrationTest {
} }
@Test @Test
fun `public tree contains only global chapters with ALL audience`() { fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL") val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS") seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
val course = newCourse() val course = newCourse()

View File

@@ -0,0 +1,272 @@
package ru.ysu.lms.academic.content
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.request.RequestPostProcessor
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.nio.file.Files
import java.util.UUID
/**
* Аудитория глобальных глав: аноним видит только ALL, студент — ALL/STUDENTS,
* преподаватель — ALL/TEACHERS, админ — всё. Курсовые главы аудитории не имеют.
*/
@Testcontainers
@SpringBootTest
@Transactional
class GlobalAudienceAccessIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
@JvmStatic
@DynamicPropertySource
fun assetsDir(registry: DynamicPropertyRegistry) {
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
}
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var authoring: ContentAuthoringService
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var chapters: ContentChapterRepository
@Autowired
private lateinit var sections: ContentSectionRepository
@Autowired
private lateinit var articles: ContentArticleRepository
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `global tree is filtered by role audience`() {
val all = seedMaterial(AUDIENCE_ALL)
val students = seedMaterial(AUDIENCE_STUDENTS)
val teachers = seedMaterial(AUDIENCE_TEACHERS)
mockMvc
.perform(get("/api/v1/content/tree"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug)))
mockMvc
.perform(get("/api/v1/content/tree").with(jwtFor("STUDENT")))
.andExpect(status().isOk)
.andExpect(
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, students.chapter.slug)),
)
mockMvc
.perform(get("/api/v1/content/tree").with(jwtFor("TEACHER")))
.andExpect(status().isOk)
.andExpect(
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, teachers.chapter.slug)),
)
mockMvc
.perform(get("/api/v1/content/tree/all").with(jwtFor("ADMIN")))
.andExpect(status().isOk)
.andExpect(
jsonPath(
"$.chapters[*].slug",
containsInAnyOrder(all.chapter.slug, students.chapter.slug, teachers.chapter.slug),
),
)
}
@Test
fun `global article access follows audience and role`() {
val students = seedMaterial(AUDIENCE_STUDENTS)
val teachers = seedMaterial(AUDIENCE_TEACHERS)
mockMvc
.perform(get(articlePath(students)))
.andExpect(status().isNotFound)
mockMvc
.perform(get(articlePath(students)).with(jwtFor("STUDENT")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value(students.article.title))
mockMvc
.perform(get(articlePath(students)).with(jwtFor("TEACHER")))
.andExpect(status().isNotFound)
mockMvc
.perform(get(articlePath(teachers)).with(jwtFor("TEACHER")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value(teachers.article.title))
mockMvc
.perform(get(articlePath(teachers)).with(jwtFor("STUDENT")))
.andExpect(status().isNotFound)
}
@Test
fun `authoring creates requested audience and rejects invalid`() {
val node = authoring.createChapter("Студенческая глава ${UUID.randomUUID()}", null, audience = AUDIENCE_STUDENTS)
assertThat(chapters.findById(UUID.fromString(node.id)).orElseThrow().audience).isEqualTo(AUDIENCE_STUDENTS)
val defaultNode = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", null)
assertThat(chapters.findById(UUID.fromString(defaultNode.id)).orElseThrow().audience).isEqualTo(AUDIENCE_ALL)
mockMvc
.perform(
post("/api/v1/content/chapters")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Неверная аудитория","audience":"EVERYONE"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isBadRequest)
}
@Test
fun `course chapter never gets audience and cannot change it`() {
val courseId =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс аудитории ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
val node =
authoring.createChapter(
"Курсовая глава ${UUID.randomUUID()}",
null,
courseId,
audience = AUDIENCE_STUDENTS,
)
val chapterId = UUID.fromString(node.id)
assertThat(chapters.findById(chapterId).orElseThrow().audience).isNull()
mockMvc
.perform(
put("/api/v1/content/chapters/{id}/audience", chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"audience":"TEACHERS"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isBadRequest)
}
@Test
fun `changing audience immediately changes access`() {
val material = seedMaterial(AUDIENCE_STUDENTS)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
.andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
.andExpect(status().isNotFound)
mockMvc
.perform(
put("/api/v1/content/chapters/{id}/audience", material.chapter.id)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"audience":"TEACHERS"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
.andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
.andExpect(status().isNotFound)
}
private fun seedMaterial(audience: String): SeededMaterial {
val chapter =
chapters.saveAndFlush(
ContentChapter(
title = "Глава ${UUID.randomUUID()}",
slug = "chapter-${UUID.randomUUID()}",
position = 1,
published = true,
audience = audience,
),
)
val section =
sections.saveAndFlush(
ContentSection(
chapterId = chapter.id,
title = "Раздел ${UUID.randomUUID()}",
slug = "section-${UUID.randomUUID()}",
position = 1,
published = true,
),
)
val article =
articles.saveAndFlush(
ContentArticle(
sectionId = section.id,
title = "Статья ${UUID.randomUUID()}",
slug = "article-${UUID.randomUUID()}",
bodyMd = "Текст",
position = 1,
published = true,
),
)
return SeededMaterial(chapter, section, article)
}
private fun articlePath(material: SeededMaterial): String =
"/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"
private fun jwtFor(role: String): RequestPostProcessor =
jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(UUID.randomUUID().toString()).claim("role", role) }
private data class SeededMaterial(
val chapter: ContentChapter,
val section: ContentSection,
val article: ContentArticle,
)
}

View File

@@ -129,7 +129,7 @@ class CourseAccessServiceMutationTest {
@Test @Test
fun `lab without course is admin only`() { fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса") val lab = Lab(number = 1, title = "Без курса", status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab) every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode { assertThatCode {
@@ -143,7 +143,7 @@ class CourseAccessServiceMutationTest {
@Test @Test
fun `lab access follows course`() { fun `lab access follows course`() {
val active = course(Course.STATUS_ACTIVE) val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Лабораторная", course = active) val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
val groupId = UUID.randomUUID() val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab) every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
@@ -210,7 +210,7 @@ class CourseAccessServiceMutationTest {
val active = course(Course.STATUS_ACTIVE) val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE) val foreign = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID() val teacherId = UUID.randomUUID()
val lab = Lab(number = 1, title = "Лабораторная", course = foreign) val lab = Lab(number = 1, title = "Лабораторная", course = foreign, status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab) every { labs.findById(lab.id) } returns Optional.of(lab)
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId)) listOf(CourseTeacher(course = active, accountId = teacherId))
@@ -219,6 +219,22 @@ class CourseAccessServiceMutationTest {
.isInstanceOf(ForbiddenException::class.java) .isInstanceOf(ForbiddenException::class.java)
} }
@Test
fun `draft lab is hidden from student`() {
val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java)
}
@Test @Test
fun `missing lab is not found`() { fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty() every { labs.findById(any()) } returns Optional.empty()

View File

@@ -199,7 +199,7 @@ class CourseAccessServiceTest {
@Test @Test
fun `lab access follows its course`() { fun `lab access follows its course`() {
val active = course() val active = course()
val lab = Lab(number = 1, title = "Лабораторная", course = active) val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
val groupId = UUID.randomUUID() val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab) every { labs.findById(lab.id) } returns Optional.of(lab)
stubStudentGroup(active, groupId) stubStudentGroup(active, groupId)
@@ -211,7 +211,7 @@ class CourseAccessServiceTest {
@Test @Test
fun `lab without course is admin only`() { fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса") val lab = Lab(number = 1, title = "Без курса", status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab) every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode { assertThatCode {
@@ -222,6 +222,19 @@ class CourseAccessServiceTest {
}.isInstanceOf(ForbiddenException::class.java) }.isInstanceOf(ForbiddenException::class.java)
} }
@Test
fun `student cannot access a draft lab of own course`() {
val active = course()
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
stubStudentGroup(active, groupId)
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(NotFoundException::class.java)
}
@Test @Test
fun `missing lab is not found`() { fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty() every { labs.findById(any()) } returns Optional.empty()

View File

@@ -164,6 +164,34 @@ class LabDetailsIntegrationTest {
).andExpect(status().isForbidden) ).andExpect(status().isForbidden)
} }
@Test
fun `student sees published labs but not drafts in list details or by id`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
val publishedId = createLab(courseId, 1, "Опубликованная", emptyList(), emptyList(), status = "published")
val draftId = createLab(courseId, 2, "Черновик", emptyList(), emptyList(), status = "draft")
val student = jwtFor(UUID.randomUUID(), "STUDENT")
mockMvc
.perform(get("/api/v1/labs").param("courseId", courseId.toString()).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
.andExpect(jsonPath("$[0].status").value("published"))
mockMvc
.perform(get("/api/v1/labs/details").param("courseId", courseId.toString()).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
mockMvc
.perform(get("/api/v1/labs/{id}", draftId).with(student))
.andExpect(status().isNotFound)
}
@Test @Test
fun `course without labs returns an empty details list`() { fun `course without labs returns an empty details list`() {
val courseId = newCourse() val courseId = newCourse()
@@ -228,13 +256,15 @@ class LabDetailsIntegrationTest {
title: String, title: String,
levels: List<LevelSaveRequest>, levels: List<LevelSaveRequest>,
variants: List<SeededVariant>, variants: List<SeededVariant>,
) { status: String = "published",
): UUID {
val created = val created =
labService.create( labService.create(
LabCreateRequest( LabCreateRequest(
number = number, number = number,
title = title, title = title,
courseId = courseId, courseId = courseId,
status = status,
levels = levels, levels = levels,
), ),
) )
@@ -251,6 +281,7 @@ class LabDetailsIntegrationTest {
), ),
) )
} }
return labId
} }
private fun jwtFor( private fun jwtFor(

View File

@@ -41,6 +41,7 @@ data class AuthResponse(
val mustChangePassword: Boolean, val mustChangePassword: Boolean,
val refreshToken: String? = null, val refreshToken: String? = null,
val accessTokenExpiresIn: Long = 0, val accessTokenExpiresIn: Long = 0,
val readOnly: Boolean = false,
) )
data class MeResponse( data class MeResponse(
@@ -50,4 +51,5 @@ data class MeResponse(
val name: String, val name: String,
val mustChangePassword: Boolean, val mustChangePassword: Boolean,
val active: Boolean = true, val active: Boolean = true,
val readOnly: Boolean = false,
) )

View File

@@ -94,6 +94,11 @@ class AuthService(
loginDeniedReason(account)?.let { throw UnauthorizedException(it) } loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
} }
/** Академический отпуск: вход разрешён, но писать данные нельзя. */
private fun readOnly(account: Account): Boolean =
account.role == LmsRole.STUDENT &&
students.findById(account.id).map { it.status == StudentStatus.ACADEMIC_LEAVE }.orElse(false)
private fun ensureLoginAllowed(account: Account) { private fun ensureLoginAllowed(account: Account) {
loginDeniedReason(account)?.let { throw UnauthorizedException(it) } loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
} }
@@ -129,6 +134,7 @@ class AuthService(
name = displayName(account), name = displayName(account),
mustChangePassword = account.mustChangePassword, mustChangePassword = account.mustChangePassword,
active = loginDeniedReason(account) == null, active = loginDeniedReason(account) == null,
readOnly = readOnly(account),
) )
} }
@@ -189,6 +195,7 @@ class AuthService(
mustChangePassword = account.mustChangePassword, mustChangePassword = account.mustChangePassword,
refreshToken = refreshToken, refreshToken = refreshToken,
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds, accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
readOnly = readOnly(account),
) )
private fun issueToken( private fun issueToken(

View File

@@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -37,24 +36,62 @@ class GroupController(
@PostMapping @PostMapping
fun create( fun create(
@Valid @RequestBody body: CreateGroupRequest, @Valid @RequestBody body: CreateGroupRequest,
): GroupSummary = groupService.create(body) @AuthenticationPrincipal jwt: Jwt,
): GroupSummary =
groupService.create(body).also {
auditService.record(
jwt.toAuditRequest(
action = "GROUP_CREATE",
entityType = "GROUP",
entityId = it.id,
details = "name=${it.name}",
),
)
}
@PostMapping("/{id}/students") @PostMapping("/{id}/students")
fun createStudent( fun createStudent(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest, @Valid @RequestBody body: CreateStudentRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject)) ): Credential =
groupService.createStudent(id, body, UUID.fromString(jwt.subject)).also {
auditService.record(
jwt.toAuditRequest(
action = "STUDENT_CREATE",
entityType = "STUDENT",
entityId = it.id,
studentId = it.id,
details = "group=$id, studentNo=${it.studentNo}",
),
)
}
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): ImportResult { ): ImportResult {
if (file.isEmpty) { if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы") throw BadRequestException("Выберите файл со списком группы")
} }
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject)) val result =
importService.import(
file.originalFilename ?: "group.xlsx",
file.bytes,
UUID.fromString(jwt.subject),
restoreInactiveStatuses,
)
auditService.record(
jwt.toAuditRequest(
action = "GROUP_IMPORT",
entityType = "GROUP",
entityId = result.groupId,
details = "added=${result.added.size}, updated=${result.updated.size}, skipped=${result.skipped.size}",
),
)
return result
} }
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */ /** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@@ -69,15 +106,18 @@ class GroupController(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable studentId: UUID, @PathVariable studentId: UUID,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject)) ): GroupDetail =
groupService.enroll(id, studentId, UUID.fromString(jwt.subject)).also {
/** Отчислить студента из группы. */ auditService.record(
@DeleteMapping("/{id}/students/{studentId}") jwt.toAuditRequest(
fun unenroll( action = "GROUP_ENROLL",
@PathVariable id: UUID, entityType = "STUDENT",
@PathVariable studentId: UUID, entityId = studentId.toString(),
@AuthenticationPrincipal jwt: Jwt, studentId = studentId.toString(),
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject)) details = "group=$id",
),
)
}
@GetMapping("/me") @GetMapping("/me")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.auth.Passwords import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus import ru.ysu.lms.identity.domain.GroupStatus
@@ -37,6 +38,7 @@ class GroupImportService(
filename: String, filename: String,
bytes: ByteArray, bytes: ByteArray,
actorAccountId: UUID?, actorAccountId: UUID?,
restoreInactiveStatuses: Boolean = false,
): ImportResult { ): ImportResult {
val parsed = parser.parse(filename, bytes) val parsed = parser.parse(filename, bytes)
if (parsed.errors.isNotEmpty()) { if (parsed.errors.isNotEmpty()) {
@@ -56,10 +58,22 @@ class GroupImportService(
val group = findOrCreateGroup(cohort) val group = findOrCreateGroup(cohort)
val added = mutableListOf<Credential>() val added = mutableListOf<Credential>()
val updated = mutableListOf<String>() val updated = mutableListOf<String>()
val skipped = mutableListOf<ImportError>()
parsed.students.forEach { parsedStudent -> parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated) val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
moveToGroup(student, group, actorAccountId) if (student.status == StudentStatus.ACTIVE) {
moveToGroup(student, group, actorAccountId)
} else {
skipped +=
ImportError(
row = parsedStudent.row,
message =
"Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
"статус и группа не изменены. Для восстановления включите явное восстановление " +
"отчисленных/выпущенных/архивных/академических студентов.",
)
}
} }
return ImportResult( return ImportResult(
applied = true, applied = true,
@@ -67,6 +81,7 @@ class GroupImportService(
groupName = group.name, groupName = group.name,
added = added, added = added,
updated = updated, updated = updated,
skipped = skipped,
) )
} }
@@ -103,6 +118,7 @@ class GroupImportService(
cohort: GroupCohort, cohort: GroupCohort,
added: MutableList<Credential>, added: MutableList<Credential>,
updated: MutableList<String>, updated: MutableList<String>,
restoreInactiveStatuses: Boolean,
): Student { ): Student {
val existing = students.findByStudentNo(parsed.studentNo) val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) { if (existing != null) {
@@ -111,8 +127,15 @@ class GroupImportService(
existing.middleName = parsed.middleName existing.middleName = parsed.middleName
existing.basis = parsed.basis existing.basis = parsed.basis
existing.note = parsed.note existing.note = parsed.note
existing.status = StudentStatus.ACTIVE if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
existing.enrollmentYear = cohort.enrollmentYear existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
existing.account?.takeIf { it.status == AccountStatus.ARCHIVED }?.let { account ->
account.status = AccountStatus.ACTIVE
account.archivedAt = null
accounts.save(account)
}
}
students.save(existing) students.save(existing)
updated += parsed.studentNo updated += parsed.studentNo
return existing return existing

View File

@@ -172,26 +172,6 @@ class GroupService(
return detail(groupId) return detail(groupId)
} }
/** Убрать студента из группы; аккаунт и данные сохраняются. */
@Transactional
fun unenroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
val student = students.findById(studentId).orElse(null)
if (student != null) {
student.status = StudentStatus.EXPELLED
students.save(student)
}
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
return detail(groupId)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? { fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null

View File

@@ -72,6 +72,7 @@ data class ImportResult(
val groupName: String? = null, val groupName: String? = null,
val added: List<Credential> = emptyList(), val added: List<Credential> = emptyList(),
val updated: List<String> = emptyList(), val updated: List<String> = emptyList(),
val skipped: List<ImportError> = emptyList(),
val errors: List<ImportError> = emptyList(), val errors: List<ImportError> = emptyList(),
) )

View File

@@ -13,6 +13,7 @@ data class ParsedStudent(
val middleName: String, val middleName: String,
val basis: String, val basis: String,
val note: String, val note: String,
val row: Int = 0,
) { ) {
val fullName: String val fullName: String
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ") get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
@@ -300,6 +301,7 @@ class StudentImportParser {
middleName = middleName, middleName = middleName,
basis = cell(row, columns, "basis"), basis = cell(row, columns, "basis"),
note = cell(row, columns, "note"), note = cell(row, columns, "note"),
row = rowNumber,
) )
} }

View File

@@ -50,6 +50,8 @@ class GroupServiceIntegrationTest {
@Autowired lateinit var importService: GroupImportService @Autowired lateinit var importService: GroupImportService
@Autowired lateinit var lifecycleService: StudentLifecycleService
@Autowired lateinit var authService: AuthService @Autowired lateinit var authService: AuthService
@Autowired lateinit var groups: GroupRepository @Autowired lateinit var groups: GroupRepository
@@ -119,7 +121,7 @@ class GroupServiceIntegrationTest {
} }
@Test @Test
fun `transfer and expulsion keep history`() { fun `transfer keeps history`() {
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025)) val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025)) val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
val imported = importService.import("group.csv", csv(validRows()), null) val imported = importService.import("group.csv", csv(validRows()), null)
@@ -137,9 +139,6 @@ class GroupServiceIntegrationTest {
) )
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action) assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
groupService.unenroll(UUID.fromString(second.id), studentId, null)
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) }) assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
assertTrue(imported.applied) assertTrue(imported.applied)
} }
@@ -155,12 +154,118 @@ class GroupServiceIntegrationTest {
) )
authService.login(LoginRequest("9001", credential.password)) authService.login(LoginRequest("9001", credential.password))
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null) lifecycleService.expel(UUID.fromString(credential.id), null, null)
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull() val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
assertTrue(error is UnauthorizedException) assertTrue(error is UnauthorizedException)
} }
@Test
fun `import does not resurrect expelled student without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.EXPELLED, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals("контракт", preserved.basis)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("EXPELLED"))
}
@Test
fun `import restores expelled student with explicit flag and records import history`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
restoreInactiveStatuses = true,
)
val restored = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertEquals(StudentStatus.ACTIVE, restored.status)
assertEquals(2024.toShort(), restored.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(MembershipAction.IMPORT, history.findByStudentIdOrderByAtDesc(studentId).first().action)
}
@Test
fun `import preserves academic leave without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.ACADEMIC_LEAVE
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.ACADEMIC_LEAVE, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
}
@Test
fun `import still updates an active student`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван Иванович;2001;контракт"),
null,
)
val updated = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertTrue(result.updated.contains("2001"))
assertEquals(StudentStatus.ACTIVE, updated.status)
assertEquals("Иванов Иван Иванович", updated.fullName)
assertEquals("контракт", updated.basis)
assertEquals(2024.toShort(), updated.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
}
private fun validRows(): String = private fun validRows(): String =
""" """
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет

View File

@@ -20,6 +20,8 @@ class StudentImportParserTest {
assertEquals("Иван", result.students[0].firstName) assertEquals("Иван", result.students[0].firstName)
assertEquals("Иванович", result.students[0].middleName) assertEquals("Иванович", result.students[0].middleName)
assertEquals("1001", result.students[0].studentNo) assertEquals("1001", result.students[0].studentNo)
assertEquals(2, result.students[0].row)
assertEquals(3, result.students[1].row)
} }
@Test @Test

View File

@@ -95,6 +95,8 @@ class StudentLifecycleIntegrationTest {
val student = students.findById(studentId).orElseThrow() val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status) assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
assertEquals(expectedYear, student.enrollmentYear) assertEquals(expectedYear, student.enrollmentYear)
assertTrue(authService.me(studentId).readOnly)
assertTrue(authService.login(LoginRequest(credential.studentNo, credential.password)).readOnly)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id) assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
val event = val event =
@@ -140,6 +142,18 @@ class StudentLifecycleIntegrationTest {
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status) assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
} }
@Test
fun `academic leave login is read only`() {
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
val credential = createStudent(group, "7105", "Орлов", "Олег")
val studentId = UUID.fromString(credential.id)
lifecycle.academicLeave(studentId, null, null)
val login = authService.login(LoginRequest(credential.studentNo, credential.password))
assertTrue(login.readOnly)
assertTrue(authService.me(studentId).readOnly)
}
@Test @Test
fun `return from academic leave moves to requested active group`() { fun `return from academic leave moves to requested active group`() {
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024) val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)

View File

@@ -30,6 +30,7 @@ data class DefenseDto(
@Service @Service
class DefenseService( class DefenseService(
private val sessions: DefenseSessionRepository, private val sessions: DefenseSessionRepository,
private val writeGuard: StudentWriteGuard,
) { ) {
companion object { companion object {
val DURATION: Duration = Duration.ofMinutes(30) val DURATION: Duration = Duration.ofMinutes(30)
@@ -129,6 +130,7 @@ class DefenseService(
studentId: UUID, studentId: UUID,
code: String, code: String,
): DefenseDto { ): DefenseDto {
writeGuard.ensureWritable(studentId)
syncTimeouts() syncTimeouts()
val s = requireWaitingSession(studentId, code) val s = requireWaitingSession(studentId, code)
s.status = DefenseStatus.started.name s.status = DefenseStatus.started.name
@@ -163,6 +165,7 @@ class DefenseService(
studentId: UUID, studentId: UUID,
sessionId: UUID, sessionId: UUID,
): DefenseDto { ): DefenseDto {
writeGuard.ensureWritable(studentId)
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") } val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия") if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата") if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")

View File

@@ -0,0 +1,22 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Component
import ru.ysu.lms.common.ForbiddenException
import java.util.UUID
/** Академический отпуск: чтение разрешено, сдача и защиты — нет. */
@Component
class StudentWriteGuard(
private val identity: IdentityClient,
) {
fun ensureWritable(studentId: UUID) {
val status = identity.getStudentContext(studentId.toString()).status
if (status == STATUS_ACADEMIC_LEAVE) {
throw ForbiddenException("Академический отпуск: доступ только для чтения")
}
}
private companion object {
const val STATUS_ACADEMIC_LEAVE = "ACADEMIC_LEAVE"
}
}

View File

@@ -19,6 +19,7 @@ class SubmissionService(
private val submissions: SubmissionRepository, private val submissions: SubmissionRepository,
private val storage: SubmissionStorage, private val storage: SubmissionStorage,
private val academic: AcademicClient, private val academic: AcademicClient,
private val writeGuard: StudentWriteGuard,
) { ) {
@Transactional @Transactional
fun submit( fun submit(
@@ -27,6 +28,7 @@ class SubmissionService(
files: List<MultipartFile>, files: List<MultipartFile>,
token: String, token: String,
): SubmissionDto { ): SubmissionDto {
writeGuard.ensureWritable(studentId)
val payload = validatedPayload(files) val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token)) ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId) val seq = nextSeq(studentId, labId)

View File

@@ -63,7 +63,10 @@ class StudentProfileService(
val context = identity.getStudentContext(studentId.toString()) val context = identity.getStudentContext(studentId.toString())
requireProfileAccess(course, context, studentId, callerId, role) requireProfileAccess(course, context, studentId, callerId, role)
val labs = courseLabs(courseId, token) val labs =
courseLabs(courseId, token).filter { (_, lab) ->
role != ROLE_STUDENT || lab.status == "published"
}
val labIds = labs.map { it.first } val labIds = labs.map { it.first }
val sid = studentId val sid = studentId
val visibleGrades = gradesByLab(sid, labIds) val visibleGrades = gradesByLab(sid, labIds)

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.repo.DefenseSessionRepository
import java.util.UUID
class DefenseServiceAcademicLeaveTest {
@Test
fun `academic leave student cannot start or finish defense`() {
val guard = mockk<StudentWriteGuard>()
every { guard.ensureWritable(any()) } throws ForbiddenException("только чтение")
val service = DefenseService(mockk<DefenseSessionRepository>(relaxed = true), guard)
val studentId = UUID.randomUUID()
assertThatThrownBy { service.startByCode(studentId, "123456") }
.isInstanceOf(ForbiddenException::class.java)
assertThatThrownBy { service.finish(studentId, UUID.randomUUID()) }
.isInstanceOf(ForbiddenException::class.java)
}
}

View File

@@ -287,6 +287,7 @@ class StudentProfileIntegrationTest {
id = labId.toString(), id = labId.toString(),
number = number, number = number,
title = "Лабораторная $number", title = "Лабораторная $number",
status = "published",
deadlineAt = "2026-12-01T00:00:00Z", deadlineAt = "2026-12-01T00:00:00Z",
levels = levels =
listOf( listOf(

View File

@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Path import java.nio.file.Path
@@ -33,7 +34,9 @@ class SubmissionServiceTest {
} }
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) } every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
val storage = SubmissionStorage(tempDir.toString()) val storage = SubmissionStorage(tempDir.toString())
return SubmissionService(repo, storage, academic) to repo val guard = mockk<StudentWriteGuard>()
every { guard.ensureWritable(any()) } returns Unit
return SubmissionService(repo, storage, academic, guard) to repo
} }
@Test @Test
@@ -53,6 +56,20 @@ class SubmissionServiceTest {
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include") assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
} }
@Test
fun `academic leave student cannot submit`() {
val repo = mockk<SubmissionRepository>()
val academic = mockk<AcademicClient>()
val guard = mockk<StudentWriteGuard>()
every { guard.ensureWritable(studentId) } throws ForbiddenException("только чтение")
val service = SubmissionService(repo, SubmissionStorage(tempDir.toString()), academic, guard)
val file = MockMultipartFile("files", "main.c", "text/plain", "int main(void){}".toByteArray())
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
.isInstanceOf(ForbiddenException::class.java)
.hasMessageContaining("только чтение")
}
@Test @Test
fun `rejects xml project file renamed to source extension`() { fun `rejects xml project file renamed to source extension`() {
val (service, _) = service() val (service, _) = service()

View File

@@ -135,12 +135,19 @@ class AdminGroupController(
@PostMapping("/groups/import") @PostMapping("/groups/import")
fun importGroup( fun importGroup(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы") if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes) val result =
api.importGroup(
token(session),
file.originalFilename ?: "group.xlsx",
file.bytes,
restoreInactiveStatuses,
)
redirect.addFlashAttribute("importResult", result) redirect.addFlashAttribute("importResult", result)
if (!result.applied) { if (!result.applied) {
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле") redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")

View File

@@ -21,7 +21,6 @@ import ru.ysu.lms.web.api.credentials
import ru.ysu.lms.web.api.enrollStudent import ru.ysu.lms.web.api.enrollStudent
import ru.ysu.lms.web.api.groupDetail import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.resetPassword import ru.ysu.lms.web.api.resetPassword
import ru.ysu.lms.web.api.unenrollStudent
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -119,20 +118,4 @@ class AdminStudentController(
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId" "redirect:/admin/groups/$groupId"
} }
@PostMapping("/groups/{groupId}/students/{studentId}/unenroll")
fun unenrollStudent(
@PathVariable groupId: String,
@PathVariable studentId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.unenrollStudent(token(session), groupId, studentId)
redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId"
}
} }

View File

@@ -33,6 +33,7 @@ import ru.ysu.lms.web.api.reorderArticles
import ru.ysu.lms.web.api.reorderContent import ru.ysu.lms.web.api.reorderContent
import ru.ysu.lms.web.api.reorderSections import ru.ysu.lms.web.api.reorderSections
import ru.ysu.lms.web.api.setContentPublished import ru.ysu.lms.web.api.setContentPublished
import ru.ysu.lms.web.api.updateChapterAudience
import ru.ysu.lms.web.api.updateContentArticle import ru.ysu.lms.web.api.updateContentArticle
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
@@ -153,16 +154,18 @@ class ContentAdminController(
redirectBack(back, "redirect:/learn") redirectBack(back, "redirect:/learn")
} }
@Suppress("LongParameterList")
@PostMapping("/chapters/create") @PostMapping("/chapters/create")
fun createChapter( fun createChapter(
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false) slug: String?, @RequestParam(required = false) slug: String?,
@RequestParam(required = false) audience: String?,
@RequestParam(required = false) back: String?, @RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
api.createChapter(token(session), title.trim(), slug) api.createChapter(token(session), title.trim(), slug, audience)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectBack(back, "redirect:/learn") redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) { } catch (ex: ApiException) {
@@ -170,6 +173,23 @@ class ContentAdminController(
redirectBack(back, "redirect:/learn") redirectBack(back, "redirect:/learn")
} }
@PostMapping("/chapters/{id}/audience")
fun updateChapterAudience(
@PathVariable id: String,
@RequestParam audience: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.updateChapterAudience(token(session), id, audience)
redirect.addFlashAttribute("notice", "Аудитория главы изменена")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/{id}/rename") @PostMapping("/chapters/{id}/rename")
fun renameChapter( fun renameChapter(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package ru.ysu.lms.web.api package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference import org.springframework.core.ParameterizedTypeReference
@@ -165,6 +167,7 @@ fun ApiClient.createChapter(
token: String, token: String,
title: String, title: String,
slug: String?, slug: String?,
audience: String? = null,
): Map<String, Any> { ): Map<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {} val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
return call { return call {
@@ -173,12 +176,29 @@ fun ApiClient.createChapter(
.uri("/api/v1/content/chapters") .uri("/api/v1/content/chapters")
.header(HttpHeaders.AUTHORIZATION, bearer(token)) .header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "slug" to slug)) .body(mapOf("title" to title, "slug" to slug, "audience" to audience))
.retrieve() .retrieve()
.body(type) .body(type)
} }
} }
fun ApiClient.updateChapterAudience(
token: String,
chapterId: String,
audience: String,
) {
call {
client
.put()
.uri("/api/v1/content/chapters/{id}/audience", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("audience" to audience))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.renameChapter( fun ApiClient.renameChapter(
token: String, token: String,
chapterId: String, chapterId: String,

View File

@@ -39,6 +39,7 @@ fun ApiClient.importGroup(
token: String, token: String,
filename: String, filename: String,
bytes: ByteArray, bytes: ByteArray,
restoreInactiveStatuses: Boolean = false,
): ImportResultDto { ): ImportResultDto {
val parts = LinkedMultiValueMap<String, Any>() val parts = LinkedMultiValueMap<String, Any>()
parts.add( parts.add(
@@ -50,8 +51,12 @@ fun ApiClient.importGroup(
return call { return call {
client client
.post() .post()
.uri("/api/v1/groups/import") .uri { builder ->
.header(HttpHeaders.AUTHORIZATION, bearer(token)) builder
.path("/api/v1/groups/import")
.queryParam("restoreInactiveStatuses", restoreInactiveStatuses)
.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts) .body(parts)
.retrieve() .retrieve()
@@ -102,21 +107,6 @@ fun ApiClient.enrollStudent(
} }
} }
fun ApiClient.unenrollStudent(
token: String,
groupId: String,
studentId: String,
) {
call {
client
.delete()
.uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.resetPassword( fun ApiClient.resetPassword(
token: String, token: String,
studentId: String, studentId: String,

View File

@@ -19,6 +19,7 @@ data class AuthResponseDto(
val role: String = "", val role: String = "",
val name: String = "", val name: String = "",
val mustChangePassword: Boolean = false, val mustChangePassword: Boolean = false,
val readOnly: Boolean = false,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -29,6 +30,7 @@ data class MeDto(
val name: String = "", val name: String = "",
val mustChangePassword: Boolean = false, val mustChangePassword: Boolean = false,
val active: Boolean = true, val active: Boolean = true,
val readOnly: Boolean = false,
) )
data class LoginPayload( data class LoginPayload(
@@ -94,6 +96,7 @@ data class ImportResultDto(
val groupName: String? = null, val groupName: String? = null,
val added: List<CredentialDto> = emptyList(), val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(), val updated: List<String> = emptyList(),
val skipped: List<ImportErrorDto> = emptyList(),
val errors: List<ImportErrorDto> = emptyList(), val errors: List<ImportErrorDto> = emptyList(),
) )
@@ -597,6 +600,7 @@ data class ContentChapterNodeDto(
val slug: String = "", val slug: String = "",
val position: Int? = 0, val position: Int? = 0,
val published: Boolean = true, val published: Boolean = true,
val audience: String? = null,
val sections: List<ContentSectionNodeDto> = emptyList(), val sections: List<ContentSectionNodeDto> = emptyList(),
) )

View File

@@ -150,6 +150,7 @@ class AuthPageController(
role = auth.role, role = auth.role,
name = auth.name, name = auth.name,
mustChangePassword = auth.mustChangePassword, mustChangePassword = auth.mustChangePassword,
readOnly = auth.readOnly,
), ),
) )
} }

View File

@@ -23,8 +23,18 @@ class NoCachePageInterceptor : HandlerInterceptor {
handler: Any, handler: Any,
): Boolean { ): Boolean {
response.setHeader("Cache-Control", "no-store") response.setHeader("Cache-Control", "no-store")
if (isPrivatePath(request.requestURI)) {
response.setHeader("X-Robots-Tag", "noindex, nofollow")
}
return true return true
} }
private fun isPrivatePath(uri: String): Boolean = uri in PRIVATE_EXACT || PRIVATE_PREFIXES.any(uri::startsWith)
private companion object {
val PRIVATE_EXACT = setOf("/login", "/setup", "/admin", "/teacher", "/student", "/change-password")
val PRIVATE_PREFIXES = listOf("/admin/", "/teacher/", "/student/", "/change-password", "/api/", "/learn/course/")
}
} }
@Configuration @Configuration

View File

@@ -121,10 +121,10 @@ class AuthInterceptor(
user: SessionUser, user: SessionUser,
me: MeDto, me: MeDto,
) { ) {
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword) return if (me.role == user.role && me.mustChangePassword == user.mustChangePassword && me.readOnly == user.readOnly) return
session.setAttribute( session.setAttribute(
SessionKeys.USER, SessionKeys.USER,
user.copy(role = me.role, mustChangePassword = me.mustChangePassword), user.copy(role = me.role, mustChangePassword = me.mustChangePassword, readOnly = me.readOnly),
) )
} }

View File

@@ -51,7 +51,11 @@ class SessionTokenService(
) )
session.setAttribute( session.setAttribute(
SessionKeys.USER, SessionKeys.USER,
user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword), user.copy(
role = auth.role,
mustChangePassword = auth.mustChangePassword,
readOnly = auth.readOnly,
),
) )
RefreshOutcome.OK RefreshOutcome.OK
} catch (ex: ApiException) { } catch (ex: ApiException) {

View File

@@ -6,6 +6,7 @@ data class SessionUser(
val role: String, val role: String,
val name: String, val name: String,
val mustChangePassword: Boolean, val mustChangePassword: Boolean,
val readOnly: Boolean = false,
) { ) {
val isAdmin: Boolean get() = role == "ADMIN" val isAdmin: Boolean get() = role == "ADMIN"
val isTeacher: Boolean get() = role == "TEACHER" val isTeacher: Boolean get() = role == "TEACHER"

View File

@@ -155,7 +155,9 @@ class StudentController(
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" } defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
model.addAttribute("defense", defense) model.addAttribute("defense", defense)
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank()) val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending) val readOnly = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.readOnly == true
model.addAttribute("readOnly", readOnly)
model.addAttribute("canSubmit", !readOnly && canSubmit(lab.deadlineAt) && !submittedOrDefending)
"student/lab" "student/lab"
} catch (ignore: ApiException) { } catch (ignore: ApiException) {
"redirect:/student/labs" "redirect:/student/labs"

View File

@@ -0,0 +1,9 @@
User-agent: *
Allow: /learn/
Disallow: /learn/course/
Disallow: /admin/
Disallow: /teacher/
Disallow: /student/
Disallow: /change-password
Disallow: /api/
Disallow: /login

View File

@@ -240,15 +240,6 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button> <button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
</form> </form>
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Удалить зачисление в группу; для отчисления с сохранением истории используйте «Отчислить»">Убрать из группы</button>
</form>
</div> </div>
</td> </td>
</tr> </tr>
@@ -487,6 +478,23 @@
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}"> <p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b> Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</p> </p>
<div class="text-sm" th:if="${!#lists.isEmpty(importResult.skipped)}">
<p>
Пропущено строк: <b th:text="${#lists.size(importResult.skipped)}">0</b> —
статус и группа не изменены, требуется явное восстановление:
</p>
<div class="scroll-x">
<table>
<thead><tr><th>Строка</th><th>Причина</th></tr></thead>
<tbody>
<tr th:each="s : ${importResult.skipped}">
<td th:text="${s.row}">—</td>
<td th:text="${s.message}">—</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p> <p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
</div> </div>
</div> </div>

View File

@@ -101,6 +101,16 @@
</p> </p>
<div><label class="label" for="file">Файл со списком</label> <div><label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div> <input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="restoreInactiveStatuses" name="restoreInactiveStatuses" value="true">
<span>Восстанавливать отчисленных/выпущенных/архивных/академических</span>
</label>
<p class="hint">
Без флага студенты в статусах «отчислен», «выпущен», «архив» или «академический отпуск»
обновляются только по ФИО и базе: их статус и группа не меняются, а строки попадут
в отчёт как пропущенные. С флагом такие студенты снова станут активными и будут
зачислены в импортируемую группу.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button> <button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>

View File

@@ -32,6 +32,17 @@
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2> <h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span> <span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions"> <div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<form th:if="${chapter.audience != null}" class="inline-form"
th:action="@{/admin/content/chapters/{id}/audience(id=${chapter.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" value="/learn">
<select class="input filter-select" name="audience" onchange="this.form.submit()"
title="Аудитория главы — изменится сразу">
<option value="ALL" th:selected="${chapter.audience == 'ALL'}">Все</option>
<option value="STUDENTS" th:selected="${chapter.audience == 'STUDENTS'}">Студенты</option>
<option value="TEACHERS" th:selected="${chapter.audience == 'TEACHERS'}">Преподаватели</option>
</select>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}"> <input type="hidden" name="id" th:value="${chapter.id}">
@@ -206,6 +217,17 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="chTitle">Название</label> <div><label class="label" for="chTitle">Название</label>
<input class="input" id="chTitle" name="title" required></div> <input class="input" id="chTitle" name="title" required></div>
<div><label class="label" for="chAudience">Аудитория</label>
<select class="input" id="chAudience" name="audience">
<option value="ALL" selected>Все — доступно без входа</option>
<option value="STUDENTS">Студенты — после входа с ролью студента</option>
<option value="TEACHERS">Преподаватели — после входа с ролью преподавателя</option>
</select>
<p class="hint">
Главы на этой странице глобальные. <b>Все</b> видит любой посетитель,
<b>Студенты</b> и <b>Преподаватели</b> — только авторизованные пользователи
с соответствующей ролью. Изменить аудиторию можно потом в карточке главы.
</p></div>
<button class="btn btn-primary" type="submit">Создать</button> <button class="btn btn-primary" type="submit">Создать</button>
</form> </form>
</div> </div>

View File

@@ -170,6 +170,7 @@
<span th:text="${comment.text}">—</span> <span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}" <span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span> th:text="|— ${comment.author.login}|"></span>
<span class="text-sm text-muted" th:if="${comment.author == null}">— автор не указан</span>
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}" <span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span> th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
</li> </li>

View File

@@ -27,7 +27,8 @@
<h2 class="section-title">Сдача работы</h2> <h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4"> <div class="card form-card form-stack mb-4">
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p> <p class="text-sm text-muted" th:if="${readOnly}">Академический отпуск: доступ только для чтения. Сдавать решения и проходить защиты нельзя.</p>
<p class="text-sm text-muted" th:if="${!readOnly and !canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p>
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack"> <form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p> <p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
@@ -87,7 +88,7 @@
<div th:if="${defense == null}" class="text-muted"> <div th:if="${defense == null}" class="text-muted">
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить. Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
</div> </div>
<th:block th:if="${defense != null and defense.status == 'waiting'}"> <th:block th:if="${defense != null and defense.status == 'waiting' and !readOnly}">
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p> <p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
<form class="inline-form" th:action="@{/student/defenses/start}" method="post"> <form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
@@ -102,7 +103,7 @@
<p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p> <p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3> <h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div> <div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя."> <form class="mt-4" th:if="${!readOnly}" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button> <button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form> </form>

View File

@@ -12,6 +12,8 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.api.AdminUserDto import ru.ysu.lms.web.api.AdminUserDto
import ru.ysu.lms.web.api.GroupDetailDto import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.ImportErrorDto
import ru.ysu.lms.web.api.ImportResultDto
import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PromotionItemDto import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto import ru.ysu.lms.web.api.PromotionRunDetailDto
@@ -204,13 +206,65 @@ class AdminStage4TemplateTest {
assertThat(html).contains("/admin/groups/g1/archive") assertThat(html).contains("/admin/groups/g1/archive")
assertThat(html).contains("/admin/groups/g1/students/s1/academic-leave") assertThat(html).contains("/admin/groups/g1/students/s1/academic-leave")
assertThat(html).contains("/admin/groups/g1/students/s1/expel") assertThat(html).contains("/admin/groups/g1/students/s1/expel")
assertThat(html).contains("/admin/groups/g1/students/s1/unenroll") assertThat(html).doesNotContain("/admin/groups/g1/students/s1/unenroll")
assertThat(html).contains("/admin/groups/g1/students/s2/return") assertThat(html).contains("/admin/groups/g1/students/s2/return")
assertThat(html).contains("/admin/groups/g1/students/s3/restore") assertThat(html).contains("/admin/groups/g1/students/s3/restore")
assertThat(html).contains("/admin/groups/g1/students/s4/restore") assertThat(html).contains("/admin/groups/g1/students/s4/restore")
assertThat(html).contains("Вернуть в группу") assertThat(html).contains("Вернуть в группу")
assertThat(html).contains("Восстановить в группу") assertThat(html).contains("Восстановить в группу")
assertThat(html).contains("Убрать из группы") assertThat(html).doesNotContain("Убрать из группы")
}
@Test
fun `group import form requires explicit restoration flag`() {
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))
assertThat(html).contains("name=\"restoreInactiveStatuses\"")
assertThat(html).contains("value=\"true\"")
assertThat(html).contains("Восстанавливать отчисленных/выпущенных/архивных/академических")
}
@Test
fun `group import result shows skipped inactive rows`() {
val group =
GroupDetailDto(
id = "g1",
name = "ПИ-21БО",
status = "ACTIVE",
students = emptyList(),
)
val model =
linkedMapOf<String, Any?>(
"group" to group,
"labs" to emptyList<LabSummaryDto>(),
"candidates" to emptyList<Any>(),
"studentGrades" to emptyList<GroupStudentRow>(),
"labPublication" to emptyMap<String, GroupLabPublication>(),
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseId" to null,
"teacherView" to false,
"importResult" to
ImportResultDto(
applied = true,
groupId = "g1",
groupName = "ПИ-21БО",
skipped =
listOf(
ImportErrorDto(
row = 7,
message = "Студент 2001 в статусе EXPELLED: статус и группа не изменены",
),
),
),
)
val html = render("admin/group", model)
assertThat(html).contains("Пропущено строк")
assertThat(html).contains("Студент 2001 в статусе EXPELLED")
assertThat(html).contains("7")
} }
@Test @Test

View File

@@ -51,6 +51,7 @@ class TeacherGradebookTemplateTest {
assertThat(html).doesNotContain("/students/s1/archive") assertThat(html).doesNotContain("/students/s1/archive")
assertThat(html).doesNotContain("/students/s1/expel") assertThat(html).doesNotContain("/students/s1/expel")
assertThat(html).doesNotContain("/students/s1/unenroll") assertThat(html).doesNotContain("/students/s1/unenroll")
assertThat(html).doesNotContain("Убрать из группы")
assertThat(html).doesNotContain("/attestations") assertThat(html).doesNotContain("/attestations")
assertThat(html).doesNotContain("modal-add-attestation") assertThat(html).doesNotContain("modal-add-attestation")
assertThat(html).doesNotContain("modal-add-student") assertThat(html).doesNotContain("modal-add-student")

View File

@@ -0,0 +1,27 @@
package ru.ysu.lms.web.config
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
class NoCachePageInterceptorTest {
private val interceptor = NoCachePageInterceptor()
@Test
fun `private pages are not indexable`() {
listOf("/admin/groups", "/teacher", "/student/labs", "/login", "/api/v1/courses", "/learn/course/1").forEach { uri ->
val response = MockHttpServletResponse()
interceptor.preHandle(MockHttpServletRequest("GET", uri), response, Any())
assertThat(response.getHeader("X-Robots-Tag")).contains("noindex")
}
}
@Test
fun `public materials remain indexable`() {
val response = MockHttpServletResponse()
interceptor.preHandle(MockHttpServletRequest("GET", "/learn"), response, Any())
assertThat(response.getHeader("X-Robots-Tag")).isNull()
assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store")
}
}