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>() {} return call { @@ -173,12 +176,29 @@ fun ApiClient.createChapter( .uri("/api/v1/content/chapters") .header(HttpHeaders.AUTHORIZATION, bearer(token)) .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title, "slug" to slug)) + .body(mapOf("title" to title, "slug" to slug, "audience" to audience)) .retrieve() .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( token: String, chapterId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt index 66be390..abe13d1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt @@ -39,6 +39,7 @@ fun ApiClient.importGroup( token: String, filename: String, bytes: ByteArray, + restoreInactiveStatuses: Boolean = false, ): ImportResultDto { val parts = LinkedMultiValueMap() parts.add( @@ -50,8 +51,12 @@ fun ApiClient.importGroup( return call { client .post() - .uri("/api/v1/groups/import") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .uri { builder -> + builder + .path("/api/v1/groups/import") + .queryParam("restoreInactiveStatuses", restoreInactiveStatuses) + .build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .contentType(MediaType.MULTIPART_FORM_DATA) .body(parts) .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( token: String, studentId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 0633f4f..1f170ad 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -19,6 +19,7 @@ data class AuthResponseDto( val role: String = "", val name: String = "", val mustChangePassword: Boolean = false, + val readOnly: Boolean = false, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -29,6 +30,7 @@ data class MeDto( val name: String = "", val mustChangePassword: Boolean = false, val active: Boolean = true, + val readOnly: Boolean = false, ) data class LoginPayload( @@ -94,6 +96,7 @@ data class ImportResultDto( val groupName: String? = null, val added: List = emptyList(), val updated: List = emptyList(), + val skipped: List = emptyList(), val errors: List = emptyList(), ) @@ -597,6 +600,7 @@ data class ContentChapterNodeDto( val slug: String = "", val position: Int? = 0, val published: Boolean = true, + val audience: String? = null, val sections: List = emptyList(), ) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index e991f7f..9a0febd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -150,6 +150,7 @@ class AuthPageController( role = auth.role, name = auth.name, mustChangePassword = auth.mustChangePassword, + readOnly = auth.readOnly, ), ) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt index bd95d40..789d517 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt @@ -23,8 +23,18 @@ class NoCachePageInterceptor : HandlerInterceptor { handler: Any, ): Boolean { response.setHeader("Cache-Control", "no-store") + if (isPrivatePath(request.requestURI)) { + response.setHeader("X-Robots-Tag", "noindex, nofollow") + } 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 diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt index cfbc5d9..fdc0656 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt @@ -121,10 +121,10 @@ class AuthInterceptor( user: SessionUser, 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( SessionKeys.USER, - user.copy(role = me.role, mustChangePassword = me.mustChangePassword), + user.copy(role = me.role, mustChangePassword = me.mustChangePassword, readOnly = me.readOnly), ) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt index 4ce8f48..65041de 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt @@ -51,7 +51,11 @@ class SessionTokenService( ) session.setAttribute( SessionKeys.USER, - user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword), + user.copy( + role = auth.role, + mustChangePassword = auth.mustChangePassword, + readOnly = auth.readOnly, + ), ) RefreshOutcome.OK } catch (ex: ApiException) { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt index 5d2c6d4..f94e2e8 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt @@ -6,6 +6,7 @@ data class SessionUser( val role: String, val name: String, val mustChangePassword: Boolean, + val readOnly: Boolean = false, ) { val isAdmin: Boolean get() = role == "ADMIN" val isTeacher: Boolean get() = role == "TEACHER" diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index 8082e21..673cf0b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -155,7 +155,9 @@ class StudentController( defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" } model.addAttribute("defense", defense) 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" } catch (ignore: ApiException) { "redirect:/student/labs" diff --git a/web/src/main/resources/static/robots.txt b/web/src/main/resources/static/robots.txt new file mode 100644 index 0000000..9cb9ad8 --- /dev/null +++ b/web/src/main/resources/static/robots.txt @@ -0,0 +1,9 @@ +User-agent: * +Allow: /learn/ +Disallow: /learn/course/ +Disallow: /admin/ +Disallow: /teacher/ +Disallow: /student/ +Disallow: /change-password +Disallow: /api/ +Disallow: /login diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 66e7c38..b0f87e4 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -240,15 +240,6 @@ - -
- - -
@@ -487,6 +478,23 @@

Обновлено существующих студентов: 0

+
+

+ Пропущено строк: 0 — + статус и группа не изменены, требуется явное восстановление: +

+
+ + + + + + + + +
СтрокаПричина
——
+
+

Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».

diff --git a/web/src/main/resources/templates/admin/groups.html b/web/src/main/resources/templates/admin/groups.html index 4b9f084..d8cc1d3 100644 --- a/web/src/main/resources/templates/admin/groups.html +++ b/web/src/main/resources/templates/admin/groups.html @@ -101,6 +101,16 @@

+ +

+ Без флага студенты в статусах «отчислен», «выпущен», «архив» или «академический отпуск» + обновляются только по ФИО и базе: их статус и группа не меняются, а строки попадут + в отчёт как пропущенные. С флагом такие студенты снова станут активными и будут + зачислены в импортируемую группу. +

diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 7076c7c..956c50a 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -32,6 +32,17 @@

Глава

скрыто
+
+ + + +
@@ -206,6 +217,17 @@
+
+ +

+ Главы на этой странице глобальные. Все видит любой посетитель, + Студенты и Преподаватели — только авторизованные пользователи + с соответствующей ролью. Изменить аудиторию можно потом в карточке главы. +

diff --git a/web/src/main/resources/templates/profile/student.html b/web/src/main/resources/templates/profile/student.html index 7abc8f2..2874253 100644 --- a/web/src/main/resources/templates/profile/student.html +++ b/web/src/main/resources/templates/profile/student.html @@ -170,6 +170,7 @@ — + — автор не указан diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html index ecfbcb7..3765024 100644 --- a/web/src/main/resources/templates/student/lab.html +++ b/web/src/main/resources/templates/student/lab.html @@ -27,7 +27,8 @@

Сдача работы

-

Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.

+

Академический отпуск: доступ только для чтения. Сдавать решения и проходить защиты нельзя.

+

Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.

zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.

@@ -87,7 +88,7 @@
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
- +

Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:

@@ -102,7 +103,7 @@

Ваше индивидуальное задание

- + diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt index 8434d43..f343748 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt @@ -12,6 +12,8 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver import org.thymeleaf.web.servlet.JakartaServletWebApplication import ru.ysu.lms.web.api.AdminUserDto 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.PromotionItemDto 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/students/s1/academic-leave") 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/s3/restore") assertThat(html).contains("/admin/groups/g1/students/s4/restore") 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())) + + 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( + "group" to group, + "labs" to emptyList(), + "candidates" to emptyList(), + "studentGrades" to emptyList(), + "labPublication" to emptyMap(), + "labSubmissionCounts" to emptyMap(), + "attestations" to emptyList(), + "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 diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/TeacherGradebookTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/TeacherGradebookTemplateTest.kt index a4a7dd2..00771f1 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/admin/TeacherGradebookTemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/TeacherGradebookTemplateTest.kt @@ -51,6 +51,7 @@ class TeacherGradebookTemplateTest { assertThat(html).doesNotContain("/students/s1/archive") assertThat(html).doesNotContain("/students/s1/expel") assertThat(html).doesNotContain("/students/s1/unenroll") + assertThat(html).doesNotContain("Убрать из группы") assertThat(html).doesNotContain("/attestations") assertThat(html).doesNotContain("modal-add-attestation") assertThat(html).doesNotContain("modal-add-student") diff --git a/web/src/test/kotlin/ru/ysu/lms/web/config/NoCachePageInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/config/NoCachePageInterceptorTest.kt new file mode 100644 index 0000000..2bb6d56 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/config/NoCachePageInterceptorTest.kt @@ -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") + } +}