diff --git a/.env.prod.example b/.env.prod.example
index 56a6fa2..d562764 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -32,4 +32,4 @@ LMS_SESSION_TIMEOUT=7d
LMS_SESSION_ABSOLUTE_TIMEOUT=30d
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
LMS_REFRESH_TOKEN_RETENTION=30d
-LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *
+LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
diff --git a/README.md b/README.md
index 42561d4..6a746c9 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
- Baseline: **51 claim, 348 тестов, 18/18 browser E2E**.
+ Baseline: **57 claims, 366 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера)
diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md
index 03dd80b..4f2f62e 100644
--- a/deploy/DEPLOY.md
+++ b/deploy/DEPLOY.md
@@ -65,7 +65,7 @@ LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и о
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
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`,
diff --git a/deploy/backup.sh b/deploy/backup.sh
index 3e3b9ac..8ef9938 100755
--- a/deploy/backup.sh
+++ b/deploy/backup.sh
@@ -35,6 +35,7 @@ usage() {
usage:
deploy/backup.sh backup [--if-running]
deploy/backup.sh list
+ deploy/backup.sh verify-latest
deploy/backup.sh verify
deploy/backup.sh restore
@@ -50,13 +51,25 @@ require_docker() {
load_env() {
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
- set -a
- # shellcheck disable=SC1090
- source "$ENV_FILE"
- set +a
+ # Не source-им .env.prod: значения с пробелами (например, cron) иначе
+ # исполняются как команды, а произвольный shell-код из файла опасен.
+ local key value line
+ 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_USER:=lms}"
- # Переменные из .env.prod должны применяться и к путям backup.
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
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
}
+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() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
@@ -248,6 +279,9 @@ case "${1:-backup}" in
list)
list_backups
;;
+ verify-latest)
+ verify_latest_backup
+ ;;
verify)
verify_backup "${2:-}"
;;
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index 4eb94e3..dce9b99 100755
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -32,6 +32,7 @@ case "$cmd" in
up)
echo ">> Резервная копия перед обновлением (обязательно)..."
./deploy/backup.sh backup --if-running
+ ./deploy/backup.sh verify-latest
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
$COMPOSE build
echo ">> Запуск сервисов..."
diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md
index 5cab282..ace55e7 100644
--- a/docs/08-groups-courses-plan.md
+++ b/docs/08-groups-courses-plan.md
@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/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-матрица,
- gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims
- (25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт:
+ gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims
+ (27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6
diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md
index 3d7b3be..a1152f7 100644
--- a/docs/16-stage6-report.md
+++ b/docs/16-stage6-report.md
@@ -71,9 +71,9 @@
| web | 108 | 0 | 0 | 0 |
| gateway | 2 | 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; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки
-Проверено автоматическими тестами (актуальный прогон — 348 тестов, 0 failures)
+Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline
После этапа 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,
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);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.
diff --git a/docs/17-handoff.md b/docs/17-handoff.md
index 6f31eea..3367c67 100644
--- a/docs/17-handoff.md
+++ b/docs/17-handoff.md
@@ -116,8 +116,8 @@
### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`.
-- **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**.
-- **348 автотестов**, 0 failures/errors/skipped.
+- **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**.
+- **366 автотестов**, 0 failures/errors/skipped.
- Mutation testing:
- `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%.
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
```
Последний подтверждённый результат:
-- `348` тестов, 0 failures;
-- `51/51` claims;
+- `366` тестов, 0 failures;
+- `57/57` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS;
- smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
`feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`.
-3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 348 тестов
- и 51/51 claims.
+3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов
+ и 57 claims, 57/57 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
diff --git a/docs/18-goal-audit.md b/docs/18-goal-audit.md
new file mode 100644
index 0000000..365aa5c
--- /dev/null
+++ b/docs/18-goal-audit.md
@@ -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-*`.
diff --git a/docs/README.md b/docs/README.md
index 7633084..c8a0731 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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-файлы |
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
| [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/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, миграции, доступ, статусы, повышение |
## Кратко о системе
diff --git a/docs/evidence/README.md b/docs/evidence/README.md
index 3f1478a..32a970b 100644
--- a/docs/evidence/README.md
+++ b/docs/evidence/README.md
@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков:
-- **348 автоматических тестов**, 0 failures/errors;
-- **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы,
+- **366 автоматических тестов**, 0 failures/errors;
+- **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md
index 51aa65b..583103a 100644
--- a/docs/evidence/baseline-2026-10-27.md
+++ b/docs/evidence/baseline-2026-10-27.md
@@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew test --rerun-tasks
```
-Результат: **348 тестов, 0 failures, 0 errors, 0 skipped**.
+Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate
@@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew evidenceCheck
```
-Результат: **51/51 claims proven, P0 gaps = 0**.
+Результат: **57/57 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`.
Gate устроен так:
@@ -306,8 +306,8 @@ optional json fields`.
## 5. Вывод
-Текущая функциональность прошла LDD-контур: **51/51 claims proven, P0 gaps = 0**,
-**348 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
+Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**,
+**366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
масштабный load зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы».
diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json
index 9a5a078..d2300db 100644
--- a/docs/evidence/claims.json
+++ b/docs/evidence/claims.json
@@ -43,11 +43,11 @@
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
"evidence": [
"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#published global ALL asset stays public()"
],
- "limitations": "Legacy-активы без article_assets остаются публичными и вынесены в отдельный риск."
+ "limitations": "Legacy-активы без article_assets закрыты отдельными claims LDD-ASSET-002/003."
},
{
"id": "LDD-ACCESS-004",
@@ -659,16 +659,18 @@
},
{
"id": "LDD-AUTH-005",
- "title": "Refresh/logout работают через реальный gateway",
+ "title": "Gateway пропускает refresh/logout без access-JWT",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
- "claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
- "oracle": "runtime.auth_refresh",
- "runtime": [
- "runtime.auth_refresh"
- ],
- "limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
+ "claim": "Gateway не требует access-JWT для /api/v1/auth/refresh и /api/v1/auth/logout; защищённый маршрут без токена получает 401. Полный цикл refresh/logout через реальный gateway проверяется отдельным P1 runtime-claim.",
+ "oracle": "JwtAuthenticationFilterTest: public paths пропущены, protected path без токена — 401",
+ "runtime": [],
+ "limitations": "Unit-тест проверяет маршрутизацию gateway; полный обмен пары и отзыв refresh — LDD-AUTH-008.",
+ "evidence": [
+ "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",
@@ -747,6 +749,97 @@
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
],
"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 также не скрывает аудитории."
}
]
}
\ No newline at end of file
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt
index f4f2088..37137ed 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt
@@ -1,6 +1,35 @@
package ru.ysu.lms.academic.content
-/** Аудитория глобальной главы, доступной без авторизации. */
+import ru.ysu.lms.common.BadRequestException
+
+/** Аудитории глобальных глав; ALL доступна без авторизации. */
const val AUDIENCE_ALL = "ALL"
const val AUDIENCE_STUDENTS = "STUDENTS"
const val AUDIENCE_TEACHERS = "TEACHERS"
+
+/** Допустимые аудитории глобальной главы. */
+val CONTENT_AUDIENCES: Set = setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS, AUDIENCE_TEACHERS)
+
+/**
+ * Аудитории глобальных глав, доступные роли. Аноним видит только ALL,
+ * студент — ALL и STUDENTS, преподаватель — ALL и TEACHERS, администратор — всё.
+ */
+fun audiencesForRole(role: String?): Set =
+ 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"
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt
index e9a9584..a562677 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt
@@ -61,17 +61,19 @@ class ContentAuthoringService(
}
/**
- * Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug
- * среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию
- * и уникальный slug внутри курса.
+ * Глобальная глава (`courseId == null`) получает запрошенную аудиторию
+ * (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
+ * получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
*/
@Transactional
fun createChapter(
title: String,
slug: String?,
courseId: UUID? = null,
+ audience: String? = null,
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
+ val requestedAudience = normalizeAudience(audience)
val scope =
if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
@@ -88,7 +90,7 @@ class ContentAuthoringService(
title = title.trim(),
slug = cleanSlug,
position = maxPos + 1,
- audience = if (courseId == null) AUDIENCE_ALL else null,
+ audience = if (courseId == null) requestedAudience ?: AUDIENCE_ALL else null,
courseId = courseId,
),
)
@@ -98,10 +100,23 @@ class ContentAuthoringService(
slug = ch.slug,
position = ch.position,
published = ch.published,
+ audience = ch.audience,
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
fun renameChapter(
chapterId: UUID,
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt
index 9485878..c26cbad 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt
@@ -34,6 +34,7 @@ data class ChapterTreeNode(
val slug: String,
val position: Int,
val published: Boolean = true,
+ val audience: String? = null,
val sections: List,
)
@@ -115,19 +116,28 @@ class ContentService(
private val importer: ContentImporter,
private val assetStorage: AssetStorage,
) {
- /** Публичное дерево: только глобальные главы с аудиторией ALL. */
+ /**
+ * Публичное дерево: только глобальные главы, аудитория которых доступна роли.
+ * Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
+ * администратор — все аудитории. Курсовые главы здесь не показываются.
+ */
@Transactional(readOnly = true)
- fun tree(includeHidden: Boolean = false): ContentTree =
- buildTree(
+ fun tree(
+ includeHidden: Boolean = false,
+ role: String? = null,
+ ): ContentTree {
+ val allowedAudiences = audiencesForRole(role)
+ return buildTree(
chapters
.findAllByOrderByPositionAscIdAsc()
.filter {
it.courseId == null &&
- it.audience == AUDIENCE_ALL &&
+ it.audience in allowedAudiences &&
(includeHidden || it.published)
},
includeHidden = includeHidden,
)
+ }
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
@Transactional(readOnly = true)
@@ -150,9 +160,10 @@ class ContentService(
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
+ role: String? = null,
): ContentArticleView {
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
- requirePublicChapter(ch, includeHidden)
+ requirePublicChapter(ch, includeHidden, role)
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
@@ -255,9 +266,10 @@ class ContentService(
private fun requirePublicChapter(
ch: ContentChapter,
includeHidden: Boolean,
+ role: String?,
) {
requireVisible(ch.published, includeHidden, "Глава скрыта")
- if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
+ if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
}
private fun buildTree(
@@ -284,6 +296,7 @@ class ContentService(
slug = ch.slug,
position = ch.position,
published = ch.published,
+ audience = ch.audience,
sections =
(sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt
index 356e957..60f7031 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt
@@ -60,6 +60,11 @@ class ContentAdminController(
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
+ val audience: String? = null,
+ )
+
+ data class ChapterAudiencePayload(
+ val audience: String,
)
data class SectionCreatePayload(
@@ -159,13 +164,29 @@ class ContentAdminController(
fun createChapter(
@RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
- ) = authoring.createChapter(body.title, body.slug).also {
+ ) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
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}",
)
}
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt
index db1b1f4..ef643d9 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt
@@ -25,6 +25,8 @@ import java.util.UUID
* Публичное чтение учебных материалов.
*
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
+ * С JWT дополнительно открываются STUDENTS для студента и TEACHERS для преподавателя;
+ * администратор видит все аудитории. Курсовые главы отдаются только курсовыми эндпоинтами.
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
* их может скачать только админ для очистки. Привязанные активы наследуют
* видимость статьи, раздела и главы.
@@ -37,14 +39,20 @@ class ContentPublicController(
private val accessService: CourseAccessService,
) {
@GetMapping("/tree")
- fun tree() = contentService.tree()
+ fun tree(
+ @AuthenticationPrincipal jwt: Jwt?,
+ ) = contentService.tree(role = jwt?.getClaimAsString("role"))
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable chapter: String,
@PathVariable section: 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(
val id: String,
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt
index 912fafc..2ba81fb 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt
@@ -46,12 +46,13 @@ class LabController(
@RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): List {
+ val student = accessService.isStudent(jwt)
if (courseId != null) {
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()
- return labService.list(accessible)
+ return labService.list(accessible).filter { !student || it.status == "published" }
}
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@@ -61,7 +62,8 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): List {
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}")
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt
index e71ff75..b283d45 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt
@@ -67,6 +67,10 @@ class CourseAccessService(
jwt: Jwt,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
+ if (role(jwt) == ROLE_STUDENT && lab.status != "published") {
+ // Черновик не существует для студента: не раскрываем его даже прямым UUID.
+ throw NotFoundException("Лабораторная не найдена")
+ }
val courseId = lab.course?.id
if (courseId == null) {
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
@@ -84,6 +88,8 @@ class CourseAccessService(
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
+ fun isStudent(jwt: Jwt): Boolean = role(jwt) == ROLE_STUDENT
+
private fun activeCourseIds(courseIds: Collection): Set {
if (courseIds.isEmpty()) return emptySet()
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt
index 9cc137f..08adf85 100644
--- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt
+++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt
@@ -93,7 +93,7 @@ class CourseContentIntegrationTest {
}
@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")
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
val course = newCourse()
diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/GlobalAudienceAccessIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/GlobalAudienceAccessIntegrationTest.kt
new file mode 100644
index 0000000..8b0faf8
--- /dev/null
+++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/GlobalAudienceAccessIntegrationTest.kt
@@ -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(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,
+ )
+}
diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt
index 8890c48..8a67d94 100644
--- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt
+++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt
@@ -129,7 +129,7 @@ class CourseAccessServiceMutationTest {
@Test
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)
assertThatCode {
@@ -143,7 +143,7 @@ class CourseAccessServiceMutationTest {
@Test
fun `lab access follows course`() {
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()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
@@ -210,7 +210,7 @@ class CourseAccessServiceMutationTest {
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
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 { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
@@ -219,6 +219,22 @@ class CourseAccessServiceMutationTest {
.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
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()
diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt
index 198dd17..dfa2948 100644
--- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt
+++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt
@@ -199,7 +199,7 @@ class CourseAccessServiceTest {
@Test
fun `lab access follows its 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()
every { labs.findById(lab.id) } returns Optional.of(lab)
stubStudentGroup(active, groupId)
@@ -211,7 +211,7 @@ class CourseAccessServiceTest {
@Test
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)
assertThatCode {
@@ -222,6 +222,19 @@ class CourseAccessServiceTest {
}.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
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()
diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt
index c72e79f..272b442 100644
--- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt
+++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt
@@ -164,6 +164,34 @@ class LabDetailsIntegrationTest {
).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
fun `course without labs returns an empty details list`() {
val courseId = newCourse()
@@ -228,13 +256,15 @@ class LabDetailsIntegrationTest {
title: String,
levels: List,
variants: List,
- ) {
+ status: String = "published",
+ ): UUID {
val created =
labService.create(
LabCreateRequest(
number = number,
title = title,
courseId = courseId,
+ status = status,
levels = levels,
),
)
@@ -251,6 +281,7 @@ class LabDetailsIntegrationTest {
),
)
}
+ return labId
}
private fun jwtFor(
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt
index 3ab085f..6fb941a 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt
@@ -41,6 +41,7 @@ data class AuthResponse(
val mustChangePassword: Boolean,
val refreshToken: String? = null,
val accessTokenExpiresIn: Long = 0,
+ val readOnly: Boolean = false,
)
data class MeResponse(
@@ -50,4 +51,5 @@ data class MeResponse(
val name: String,
val mustChangePassword: Boolean,
val active: Boolean = true,
+ val readOnly: Boolean = false,
)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
index 1a3a023..ce163fc 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
@@ -94,6 +94,11 @@ class AuthService(
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) {
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
@@ -129,6 +134,7 @@ class AuthService(
name = displayName(account),
mustChangePassword = account.mustChangePassword,
active = loginDeniedReason(account) == null,
+ readOnly = readOnly(account),
)
}
@@ -189,6 +195,7 @@ class AuthService(
mustChangePassword = account.mustChangePassword,
refreshToken = refreshToken,
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
+ readOnly = readOnly(account),
)
private fun issueToken(
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
index fd7a3ca..ed893e7 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
@@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
-import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -37,24 +36,62 @@ class GroupController(
@PostMapping
fun create(
@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")
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
@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")
fun import(
@RequestParam("file") file: MultipartFile,
+ @RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
@AuthenticationPrincipal jwt: Jwt,
): ImportResult {
if (file.isEmpty) {
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 studentId: UUID,
@AuthenticationPrincipal jwt: Jwt,
- ): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
-
- /** Отчислить студента из группы. */
- @DeleteMapping("/{id}/students/{studentId}")
- fun unenroll(
- @PathVariable id: UUID,
- @PathVariable studentId: UUID,
- @AuthenticationPrincipal jwt: Jwt,
- ): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
+ ): GroupDetail =
+ groupService.enroll(id, studentId, UUID.fromString(jwt.subject)).also {
+ auditService.record(
+ jwt.toAuditRequest(
+ action = "GROUP_ENROLL",
+ entityType = "STUDENT",
+ entityId = studentId.toString(),
+ studentId = studentId.toString(),
+ details = "group=$id",
+ ),
+ )
+ }
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
index 8cdb2ff..ac2402a 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.auth.Passwords
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.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
@@ -37,6 +38,7 @@ class GroupImportService(
filename: String,
bytes: ByteArray,
actorAccountId: UUID?,
+ restoreInactiveStatuses: Boolean = false,
): ImportResult {
val parsed = parser.parse(filename, bytes)
if (parsed.errors.isNotEmpty()) {
@@ -56,10 +58,22 @@ class GroupImportService(
val group = findOrCreateGroup(cohort)
val added = mutableListOf()
val updated = mutableListOf()
+ val skipped = mutableListOf()
parsed.students.forEach { parsedStudent ->
- val student = upsertStudent(parsedStudent, cohort, added, updated)
- moveToGroup(student, group, actorAccountId)
+ val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
+ if (student.status == StudentStatus.ACTIVE) {
+ moveToGroup(student, group, actorAccountId)
+ } else {
+ skipped +=
+ ImportError(
+ row = parsedStudent.row,
+ message =
+ "Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
+ "статус и группа не изменены. Для восстановления включите явное восстановление " +
+ "отчисленных/выпущенных/архивных/академических студентов.",
+ )
+ }
}
return ImportResult(
applied = true,
@@ -67,6 +81,7 @@ class GroupImportService(
groupName = group.name,
added = added,
updated = updated,
+ skipped = skipped,
)
}
@@ -103,6 +118,7 @@ class GroupImportService(
cohort: GroupCohort,
added: MutableList,
updated: MutableList,
+ restoreInactiveStatuses: Boolean,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
@@ -111,8 +127,15 @@ class GroupImportService(
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
- existing.status = StudentStatus.ACTIVE
- existing.enrollmentYear = cohort.enrollmentYear
+ if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
+ 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)
updated += parsed.studentNo
return existing
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
index 6aa5c3b..2862587 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
@@ -172,26 +172,6 @@ class GroupService(
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)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
index c650699..911e5cb 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
@@ -72,6 +72,7 @@ data class ImportResult(
val groupName: String? = null,
val added: List = emptyList(),
val updated: List = emptyList(),
+ val skipped: List = emptyList(),
val errors: List = emptyList(),
)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
index aa71c74..61f192f 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
@@ -13,6 +13,7 @@ data class ParsedStudent(
val middleName: String,
val basis: String,
val note: String,
+ val row: Int = 0,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
@@ -300,6 +301,7 @@ class StudentImportParser {
middleName = middleName,
basis = cell(row, columns, "basis"),
note = cell(row, columns, "note"),
+ row = rowNumber,
)
}
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
index c85da8f..c22f46d 100644
--- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
@@ -50,6 +50,8 @@ class GroupServiceIntegrationTest {
@Autowired lateinit var importService: GroupImportService
+ @Autowired lateinit var lifecycleService: StudentLifecycleService
+
@Autowired lateinit var authService: AuthService
@Autowired lateinit var groups: GroupRepository
@@ -119,7 +121,7 @@ class GroupServiceIntegrationTest {
}
@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 second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
val imported = importService.import("group.csv", csv(validRows()), null)
@@ -137,9 +139,6 @@ class GroupServiceIntegrationTest {
)
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) })
assertTrue(imported.applied)
}
@@ -155,12 +154,118 @@ class GroupServiceIntegrationTest {
)
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()
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 =
"""
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
index 877533c..1a3e441 100644
--- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
@@ -20,6 +20,8 @@ class StudentImportParserTest {
assertEquals("Иван", result.students[0].firstName)
assertEquals("Иванович", result.students[0].middleName)
assertEquals("1001", result.students[0].studentNo)
+ assertEquals(2, result.students[0].row)
+ assertEquals(3, result.students[1].row)
}
@Test
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt
index 8891eab..5e27a2f 100644
--- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt
@@ -95,6 +95,8 @@ class StudentLifecycleIntegrationTest {
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
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)
val event =
@@ -140,6 +142,18 @@ class StudentLifecycleIntegrationTest {
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
fun `return from academic leave moves to requested active group`() {
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt
index 941c641..221fd8b 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt
@@ -30,6 +30,7 @@ data class DefenseDto(
@Service
class DefenseService(
private val sessions: DefenseSessionRepository,
+ private val writeGuard: StudentWriteGuard,
) {
companion object {
val DURATION: Duration = Duration.ofMinutes(30)
@@ -129,6 +130,7 @@ class DefenseService(
studentId: UUID,
code: String,
): DefenseDto {
+ writeGuard.ensureWritable(studentId)
syncTimeouts()
val s = requireWaitingSession(studentId, code)
s.status = DefenseStatus.started.name
@@ -163,6 +165,7 @@ class DefenseService(
studentId: UUID,
sessionId: UUID,
): DefenseDto {
+ writeGuard.ensureWritable(studentId)
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentWriteGuard.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentWriteGuard.kt
new file mode 100644
index 0000000..7da6f15
--- /dev/null
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentWriteGuard.kt
@@ -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"
+ }
+}
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt
index 60d6065..09993cc 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt
@@ -19,6 +19,7 @@ class SubmissionService(
private val submissions: SubmissionRepository,
private val storage: SubmissionStorage,
private val academic: AcademicClient,
+ private val writeGuard: StudentWriteGuard,
) {
@Transactional
fun submit(
@@ -27,6 +28,7 @@ class SubmissionService(
files: List,
token: String,
): SubmissionDto {
+ writeGuard.ensureWritable(studentId)
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt
index 371e1aa..e3269d2 100644
--- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt
+++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt
@@ -63,7 +63,10 @@ class StudentProfileService(
val context = identity.getStudentContext(studentId.toString())
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 sid = studentId
val visibleGrades = gradesByLab(sid, labIds)
diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/DefenseServiceAcademicLeaveTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/DefenseServiceAcademicLeaveTest.kt
new file mode 100644
index 0000000..02db8ef
--- /dev/null
+++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/DefenseServiceAcademicLeaveTest.kt
@@ -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()
+ every { guard.ensureWritable(any()) } throws ForbiddenException("только чтение")
+ val service = DefenseService(mockk(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)
+ }
+}
diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt
index 34302ee..14d3ea3 100644
--- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt
+++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt
@@ -287,6 +287,7 @@ class StudentProfileIntegrationTest {
id = labId.toString(),
number = number,
title = "Лабораторная $number",
+ status = "published",
deadlineAt = "2026-12-01T00:00:00Z",
levels =
listOf(
diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionServiceTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionServiceTest.kt
index 73ac55f..3ce5473 100644
--- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionServiceTest.kt
+++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionServiceTest.kt
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
+import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Path
@@ -33,7 +34,9 @@ class SubmissionServiceTest {
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
val storage = SubmissionStorage(tempDir.toString())
- return SubmissionService(repo, storage, academic) to repo
+ val guard = mockk()
+ every { guard.ensureWritable(any()) } returns Unit
+ return SubmissionService(repo, storage, academic, guard) to repo
}
@Test
@@ -53,6 +56,20 @@ class SubmissionServiceTest {
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
}
+ @Test
+ fun `academic leave student cannot submit`() {
+ val repo = mockk()
+ val academic = mockk()
+ val guard = mockk()
+ 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
fun `rejects xml project file renamed to source extension`() {
val (service, _) = service()
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
index a35f351..bfcbf9a 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
@@ -135,12 +135,19 @@ class AdminGroupController(
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
+ @RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
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)
if (!result.applied) {
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
index 1dc9b24..63f1794 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
@@ -21,7 +21,6 @@ import ru.ysu.lms.web.api.credentials
import ru.ysu.lms.web.api.enrollStudent
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.resetPassword
-import ru.ysu.lms.web.api.unenrollStudent
import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -119,20 +118,4 @@ class AdminStudentController(
redirect.addFlashAttribute("error", ex.message)
"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"
- }
}
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt
index 0cb171b..95c4cf8 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt
@@ -33,6 +33,7 @@ import ru.ysu.lms.web.api.reorderArticles
import ru.ysu.lms.web.api.reorderContent
import ru.ysu.lms.web.api.reorderSections
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.session.SessionKeys
@@ -153,16 +154,18 @@ class ContentAdminController(
redirectBack(back, "redirect:/learn")
}
+ @Suppress("LongParameterList")
@PostMapping("/chapters/create")
fun createChapter(
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
+ @RequestParam(required = false) audience: String?,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
- api.createChapter(token(session), title.trim(), slug)
+ api.createChapter(token(session), title.trim(), slug, audience)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
@@ -170,6 +173,23 @@ class ContentAdminController(
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")
fun renameChapter(
@PathVariable id: String,
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
index b8b6c5f..413366c 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
@@ -1,3 +1,5 @@
+@file:Suppress("TooManyFunctions")
+
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
@@ -165,6 +167,7 @@ fun ApiClient.createChapter(
token: String,
title: String,
slug: String?,
+ audience: String? = null,
): Map {
val type = object : ParameterizedTypeReference
+
+
+ Без флага студенты в статусах «отчислен», «выпущен», «архив» или «академический отпуск»
+ обновляются только по ФИО и базе: их статус и группа не меняются, а строки попадут
+ в отчёт как пропущенные. С флагом такие студенты снова станут активными и будут
+ зачислены в импортируемую группу.
+