fix: закрыть P0/P1 расхождения с целевой моделью
- академический отпуск: readOnly, запрет submit/start/finish защиты - черновики лабораторных скрыты от студентов в списке/деталях/по UUID - удалено действие «Убрать из группы» из UI и API - импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта - глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории - LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0 - backup: безопасный парсинг .env, verify-latest, кавычки cron - robots/noindex, комментарии без автора, профиль без черновиков - docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
@@ -32,4 +32,4 @@ LMS_SESSION_TIMEOUT=7d
|
|||||||
LMS_SESSION_ABSOLUTE_TIMEOUT=30d
|
LMS_SESSION_ABSOLUTE_TIMEOUT=30d
|
||||||
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
|
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
|
||||||
LMS_REFRESH_TOKEN_RETENTION=30d
|
LMS_REFRESH_TOKEN_RETENTION=30d
|
||||||
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *
|
LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
|||||||
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
|
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
|
||||||
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
|
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
|
||||||
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
|
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
|
||||||
Baseline: **51 claim, 348 тестов, 18/18 browser E2E**.
|
Baseline: **57 claims, 366 тестов, 18/18 browser E2E**.
|
||||||
|
|
||||||
## Запуск в Docker (для сервера)
|
## Запуск в Docker (для сервера)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и о
|
|||||||
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
|
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
|
||||||
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
|
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
|
||||||
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
|
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
|
||||||
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *
|
LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
|
||||||
```
|
```
|
||||||
|
|
||||||
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,
|
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ usage() {
|
|||||||
usage:
|
usage:
|
||||||
deploy/backup.sh backup [--if-running]
|
deploy/backup.sh backup [--if-running]
|
||||||
deploy/backup.sh list
|
deploy/backup.sh list
|
||||||
|
deploy/backup.sh verify-latest
|
||||||
deploy/backup.sh verify <backup-dir>
|
deploy/backup.sh verify <backup-dir>
|
||||||
deploy/backup.sh restore <backup-dir>
|
deploy/backup.sh restore <backup-dir>
|
||||||
|
|
||||||
@@ -50,13 +51,25 @@ require_docker() {
|
|||||||
|
|
||||||
load_env() {
|
load_env() {
|
||||||
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
|
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
|
||||||
set -a
|
# Не source-им .env.prod: значения с пробелами (например, cron) иначе
|
||||||
# shellcheck disable=SC1090
|
# исполняются как команды, а произвольный shell-код из файла опасен.
|
||||||
source "$ENV_FILE"
|
local key value line
|
||||||
set +a
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
value="${line#*=}"
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
value="${value#\"}"; value="${value%\"}"
|
||||||
|
value="${value#\'\'}"; value="${value%\'\'}"
|
||||||
|
case "$key" in
|
||||||
|
LMS_DB_NAME | LMS_DB_USER | LMS_DB_PASSWORD | LMS_WEB_PORT | \
|
||||||
|
LMS_BACKUP_DIR | LMS_BACKUP_KEEP_DAYS | LMS_COMPOSE_PROJECT)
|
||||||
|
export "$key=$value"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <"$ENV_FILE"
|
||||||
: "${LMS_DB_NAME:=lms}"
|
: "${LMS_DB_NAME:=lms}"
|
||||||
: "${LMS_DB_USER:=lms}"
|
: "${LMS_DB_USER:=lms}"
|
||||||
# Переменные из .env.prod должны применяться и к путям backup.
|
|
||||||
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
|
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
|
||||||
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
|
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
|
||||||
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
|
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
|
||||||
@@ -164,6 +177,24 @@ list_backups() {
|
|||||||
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
|
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latest_backup() {
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
load_env
|
||||||
|
fi
|
||||||
|
[[ -d "$BACKUP_ROOT" ]] || return 0
|
||||||
|
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -print 2>/dev/null | sort | tail -n 1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_latest_backup() {
|
||||||
|
local dir
|
||||||
|
dir="$(latest_backup)"
|
||||||
|
if [[ -z "$dir" ]]; then
|
||||||
|
info "Backup ещё не создавался — проверять нечего"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
verify_backup "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
verify_backup() {
|
verify_backup() {
|
||||||
local dir="${1:-}"
|
local dir="${1:-}"
|
||||||
[[ -n "$dir" ]] || usage
|
[[ -n "$dir" ]] || usage
|
||||||
@@ -248,6 +279,9 @@ case "${1:-backup}" in
|
|||||||
list)
|
list)
|
||||||
list_backups
|
list_backups
|
||||||
;;
|
;;
|
||||||
|
verify-latest)
|
||||||
|
verify_latest_backup
|
||||||
|
;;
|
||||||
verify)
|
verify)
|
||||||
verify_backup "${2:-}"
|
verify_backup "${2:-}"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ case "$cmd" in
|
|||||||
up)
|
up)
|
||||||
echo ">> Резервная копия перед обновлением (обязательно)..."
|
echo ">> Резервная копия перед обновлением (обязательно)..."
|
||||||
./deploy/backup.sh backup --if-running
|
./deploy/backup.sh backup --if-running
|
||||||
|
./deploy/backup.sh verify-latest
|
||||||
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
|
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
|
||||||
$COMPOSE build
|
$COMPOSE build
|
||||||
echo ">> Запуск сервисов..."
|
echo ">> Запуск сервисов..."
|
||||||
|
|||||||
@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
|
|||||||
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
|
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
|
||||||
ведомость, группы, материалы), центральный аудит `identity.audit_events`
|
ведомость, группы, материалы), центральный аудит `identity.audit_events`
|
||||||
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
|
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
|
||||||
тестов/линтеров (**348**, 0 failures), upgrade миграций с master-схемы
|
тестов/линтеров (**366**, 0 failures), upgrade миграций с master-схемы
|
||||||
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
|
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
|
||||||
gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims
|
gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims
|
||||||
(25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт:
|
(27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
|
||||||
[16-stage6-report.md](16-stage6-report.md).
|
[16-stage6-report.md](16-stage6-report.md).
|
||||||
|
|
||||||
### 9.1. Известные пробелы после этапа 6
|
### 9.1. Известные пробелы после этапа 6
|
||||||
|
|||||||
@@ -71,9 +71,9 @@
|
|||||||
| web | 108 | 0 | 0 | 0 |
|
| web | 108 | 0 | 0 | 0 |
|
||||||
| gateway | 2 | 0 | 0 | 0 |
|
| gateway | 2 | 0 | 0 | 0 |
|
||||||
| markdown | 5 | 0 | 0 | 0 |
|
| markdown | 5 | 0 | 0 | 0 |
|
||||||
| **Итого на момент этапа 6** | **348** | **0** | **0** | **0** |
|
| **Итого на момент этапа 6** | **366** | **0** | **0** | **0** |
|
||||||
|
|
||||||
После закрытия рисков актуальный LDD-baseline — **348 тестов, 0 failures**
|
После закрытия рисков актуальный LDD-baseline — **366 тестов, 0 failures**
|
||||||
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
|
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
|
||||||
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
|
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
|
||||||
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
|
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
|
||||||
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
|
|||||||
|
|
||||||
### 2.3. Security-проверки
|
### 2.3. Security-проверки
|
||||||
|
|
||||||
Проверено автоматическими тестами (актуальный прогон — 348 тестов, 0 failures)
|
Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures)
|
||||||
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
|
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
|
||||||
negative-тестами «свой/чужой курс».
|
negative-тестами «свой/чужой курс».
|
||||||
|
|
||||||
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
|
|||||||
## 7. LDD baseline
|
## 7. LDD baseline
|
||||||
|
|
||||||
После этапа 6 и закрытия рисков текущая функциональность проверена через
|
После этапа 6 и закрытия рисков текущая функциональность проверена через
|
||||||
LDD-контур: реестр `docs/evidence/claims.json` (**51 claims, 25 P0 / 26 P1**),
|
LDD-контур: реестр `docs/evidence/claims.json` (**57 claims, 27 P0 / 30 P1**),
|
||||||
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
|
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
|
||||||
mutation testing и браузерный Playwright E2E. Результат:
|
mutation testing и браузерный Playwright E2E. Результат:
|
||||||
**51/51 claims proven, P0 gaps = 0, 348 тестов, 0 failures**; mutation strength
|
**57/57 claims proven, P0 gaps = 0, 366 тестов, 0 failures**; mutation strength
|
||||||
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
|
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
|
||||||
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
|
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
|
||||||
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.
|
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.
|
||||||
|
|||||||
@@ -116,8 +116,8 @@
|
|||||||
### 1.8. LDD и проверки
|
### 1.8. LDD и проверки
|
||||||
|
|
||||||
- Реестр: `docs/evidence/claims.json`.
|
- Реестр: `docs/evidence/claims.json`.
|
||||||
- **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**.
|
- **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**.
|
||||||
- **348 автотестов**, 0 failures/errors/skipped.
|
- **366 автотестов**, 0 failures/errors/skipped.
|
||||||
- Mutation testing:
|
- Mutation testing:
|
||||||
- `CourseAccessService` — 75% test strength;
|
- `CourseAccessService` — 75% test strength;
|
||||||
- `PromotionPlanner` — 69%.
|
- `PromotionPlanner` — 69%.
|
||||||
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Последний подтверждённый результат:
|
Последний подтверждённый результат:
|
||||||
- `348` тестов, 0 failures;
|
- `366` тестов, 0 failures;
|
||||||
- `51/51` claims;
|
- `57/57` claims, P0 gaps = 0;
|
||||||
- `ldd-release.sh` — PASS;
|
- `ldd-release.sh` — PASS;
|
||||||
- smoke — PASS;
|
- smoke — PASS;
|
||||||
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
|
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
|
||||||
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
|
|||||||
`feature/groups-courses`.
|
`feature/groups-courses`.
|
||||||
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
|
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
|
||||||
`docs/evidence/baseline-2026-10-27.md`.
|
`docs/evidence/baseline-2026-10-27.md`.
|
||||||
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 348 тестов
|
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов
|
||||||
и 51/51 claims.
|
и 57 claims, 57/57 proven.
|
||||||
4. Для release-проверки: `./scripts/ldd-release.sh`.
|
4. Для release-проверки: `./scripts/ldd-release.sh`.
|
||||||
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
|
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
|
||||||
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
|
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
|
||||||
|
|||||||
141
docs/18-goal-audit.md
Normal file
141
docs/18-goal-audit.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 18. Сверка цели и фактического состояния
|
||||||
|
|
||||||
|
**Дата:** 2026-10-29.
|
||||||
|
**Ветка:** `feature/groups-courses`.
|
||||||
|
**Источники цели:** `07-groups-courses.md`, `08-groups-courses-plan.md`,
|
||||||
|
`09-teacher-student-profile.md`, `16-stage6-report.md`, `17-handoff.md`.
|
||||||
|
|
||||||
|
Документ фиксирует, где приложение уже соответствует целевой модели, где
|
||||||
|
остались отличия и какие решения приняты по спорным местам. Это не отчёт
|
||||||
|
«всё готово»: статус `partial` означает реальный незакрытый пункт.
|
||||||
|
|
||||||
|
## 1. Сводка
|
||||||
|
|
||||||
|
| Область | Статус | Что осталось |
|
||||||
|
|---|---|---|
|
||||||
|
| Роли, группы-когорты, статусы, история | partial | Финальные `NOT NULL`/полная уникальность когорты; группа не восстанавливается из архива; история членства не показана в UI |
|
||||||
|
| Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service |
|
||||||
|
| Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID |
|
||||||
|
| «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив |
|
||||||
|
| Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями |
|
||||||
|
| Курсы, преподаватели, группы курса | implemented | — |
|
||||||
|
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
|
||||||
|
| Профиль студента | partial | Браузерный URL использует UUID вместо `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» |
|
||||||
|
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
|
||||||
|
| Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет |
|
||||||
|
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
|
||||||
|
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
|
||||||
|
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе |
|
||||||
|
| LDD | implemented | 57 claims (27 P0 / 30 P1), 57/57 proven после release; `check` доказывает P0/unit, runtime P1 — release |
|
||||||
|
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
|
||||||
|
| «Контингент» | out of scope | Исключён решением 2026-10-28 |
|
||||||
|
|
||||||
|
## 2. Что исправлено в этой итерации
|
||||||
|
|
||||||
|
1. **Read-only академотпуск.**
|
||||||
|
- `AuthResponse`/`MeResponse` содержат `readOnly`; web-сессия хранит флаг.
|
||||||
|
- `StudentWriteGuard` в progress-service блокирует submit, start и finish защиты.
|
||||||
|
- UI скрывает сдачу/защиту и показывает объяснение.
|
||||||
|
- Тесты: `StudentLifecycleIntegrationTest#academic leave login is read only`,
|
||||||
|
`SubmissionServiceTest#academic leave student cannot submit`,
|
||||||
|
`DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense`.
|
||||||
|
|
||||||
|
2. **Черновики лабораторных скрыты от студентов.**
|
||||||
|
- `CourseAccessService.requireLabAccess` отдаёт `404` студенту для черновика.
|
||||||
|
- `GET /api/v1/labs` и `/labs/details` фильтруют черновики для студента.
|
||||||
|
- Тесты: `LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id`,
|
||||||
|
`CourseAccessServiceTest#student cannot access a draft lab of own course`.
|
||||||
|
|
||||||
|
3. **Удалено действие «Убрать из группы».**
|
||||||
|
- Удалены endpoint, web-маршрут, API-клиент, кнопка и сервисный метод.
|
||||||
|
- Тесты шаблонов проверяют отсутствие действия.
|
||||||
|
|
||||||
|
4. **Импорт больше не воскрешает неактивных студентов без флага.**
|
||||||
|
- `restoreInactiveStatuses` (по умолчанию `false`), `ImportResult.skipped`.
|
||||||
|
- Без флага сохраняются `EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE`, строка попадает в отчёт.
|
||||||
|
- С флагом студент активируется, архивный аккаунт разблокируется, пишется `IMPORT`.
|
||||||
|
- Тесты: `GroupServiceIntegrationTest#import does not resurrect...`,
|
||||||
|
`#import restores expelled student...`, `#import preserves academic leave...`,
|
||||||
|
`#import still updates an active student`, шаблонные тесты отчёта.
|
||||||
|
|
||||||
|
5. **Глобальные материалы `STUDENTS`/`TEACHERS`.**
|
||||||
|
- Дерево и статьи фильтруются по роли JWT; аноним — только `ALL`.
|
||||||
|
- Админ выбирает аудиторию при создании и может изменить её.
|
||||||
|
- Тесты: `GlobalAudienceAccessIntegrationTest` (5 сценариев).
|
||||||
|
|
||||||
|
6. **LDD gate на чистом checkout.**
|
||||||
|
- `LDD-AUTH-005` теперь P0 с unit-тестом gateway; полный refresh/handshake вынесен в P1 `LDD-AUTH-008` (runtime).
|
||||||
|
- `evidenceCheck` без `build/evidence` проходит по P0; runtime P1 закрываются в release.
|
||||||
|
|
||||||
|
7. **Backup/env.**
|
||||||
|
- `LMS_REFRESH_TOKEN_CLEANUP_CRON` в примерах закавычен.
|
||||||
|
- `backup.sh` больше не `source`-ит `.env.prod` (не исполняет cron как команду).
|
||||||
|
- `deploy.sh up` после backup вызывает `verify-latest`.
|
||||||
|
|
||||||
|
8. **Robots/noindex.**
|
||||||
|
- `robots.txt`; `X-Robots-Tag: noindex, nofollow` для admin/teacher/student/login/api/курсовых материалов.
|
||||||
|
|
||||||
|
9. **Профиль.**
|
||||||
|
- Старые комментарии без автора показывают «автор не указан».
|
||||||
|
- Студенческий профиль не показывает черновики лабораторных.
|
||||||
|
|
||||||
|
## 3. Остаточные отличия от цели
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- **Импорт: ошибки БД.** Ошибки lookup/конфликта всё ещё могут выйти одним
|
||||||
|
flash-сообщением без номера строки; нужен row-level отчёт и для них.
|
||||||
|
- **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null`
|
||||||
|
и не создаёт план за предыдущий год; после долгого простоя в январе
|
||||||
|
пропущенный август не догоняется.
|
||||||
|
- **Профиль: контракт.** Браузерные маршруты используют UUID курса, а docs/09
|
||||||
|
обещает `courseNo`; часть полей `variant/level/grade` отличается от контракта.
|
||||||
|
Нужно либо привести код к контракту, либо официально обновить контракт.
|
||||||
|
- **Backup restore на staging** не подтверждён артефактом/логом.
|
||||||
|
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- Когорта: поля всё ещё nullable, уникальность частичная; нужен preflight и
|
||||||
|
финальный `SET NOT NULL`/полный unique index после ручного разбора legacy.
|
||||||
|
- Нет восстановления группы из архива (сейчас архив односторонний).
|
||||||
|
- Нет пагинации списков материалов и групп (аудит и профиль уже лучше).
|
||||||
|
- Попытки сдачи в профиле не ограничены/не пагинированы.
|
||||||
|
- Сдачи в ведомости запрашиваются по выбранной лабораторной, а не пакетно.
|
||||||
|
- Нет визуальной регрессии и полного мобильного набора E2E.
|
||||||
|
- Нет тестов на audit-события `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL`.
|
||||||
|
- Mutation testing не 100%, нагрузочный smoke не SLA.
|
||||||
|
|
||||||
|
### Операционные
|
||||||
|
|
||||||
|
- Инвентаризация legacy-активов на реальном хранилище.
|
||||||
|
- Установка/проверка daily backup timer на сервере.
|
||||||
|
- Первый push-MR и tag pipeline в Woodpecker.
|
||||||
|
- Restore на staging.
|
||||||
|
|
||||||
|
## 4. Принятые решения
|
||||||
|
|
||||||
|
- `ACADEMIC_LEAVE`: блокировать только сдачу и защиты; чтение и скачивание
|
||||||
|
своих файлов разрешены.
|
||||||
|
- «Убрать из группы»: действие удалено полностью.
|
||||||
|
- Импорт терминальных/академических статусов: только по явному флагу
|
||||||
|
`restoreInactiveStatuses`.
|
||||||
|
- Глобальные материалы: `ALL/STUDENTS/TEACHERS` поддерживаются; `STUDENTS`
|
||||||
|
видит студент, `TEACHERS` — преподаватель, аноним — только `ALL`.
|
||||||
|
- «Контингент» остаётся вне scope.
|
||||||
|
|
||||||
|
## 5. LDD
|
||||||
|
|
||||||
|
- Реестр: `docs/evidence/claims.json`, **57 claims** (27 P0 / 30 P1).
|
||||||
|
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
|
||||||
|
- `./scripts/ldd-release.sh` — PASS: **57/57 claims**, 366 тестов, mutation
|
||||||
|
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
|
||||||
|
runtime/load p95 < 1 с.
|
||||||
|
|
||||||
|
## 6. Следующий порядок работ
|
||||||
|
|
||||||
|
1. Закрыть P1: row-level ошибки импорта, догон повышения, контракт профиля,
|
||||||
|
staging restore, первый CI pipeline.
|
||||||
|
2. Закрыть P2: финальная миграция когорты, unarchive, пагинация, audit-тесты,
|
||||||
|
визуальная регрессия.
|
||||||
|
3. Прогнать полный release и обновить `evidence/baseline-*`.
|
||||||
@@ -25,8 +25,9 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
|
|||||||
| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы |
|
| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы |
|
||||||
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
|
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
|
||||||
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
|
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
|
||||||
|
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
|
||||||
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
|
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
|
||||||
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 51 claims, 348 тестов, upgrade, runtime/scale, mutation и browser E2E |
|
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 57 claims, 366 тестов, upgrade, runtime/scale, mutation и browser E2E |
|
||||||
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
|
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
|
||||||
|
|
||||||
## Кратко о системе
|
## Кратко о системе
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
|
|||||||
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
|
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
|
||||||
рисков:
|
рисков:
|
||||||
|
|
||||||
- **348 автоматических тестов**, 0 failures/errors;
|
- **366 автоматических тестов**, 0 failures/errors;
|
||||||
- **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы,
|
- **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы,
|
||||||
повышение, жизненный цикл, профиль/скачивание, материалы/активы
|
повышение, жизненный цикл, профиль/скачивание, материалы/активы
|
||||||
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
|
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
|
||||||
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
|
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
|
|||||||
./gradlew test --rerun-tasks
|
./gradlew test --rerun-tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
Результат: **348 тестов, 0 failures, 0 errors, 0 skipped**.
|
Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**.
|
||||||
`ktlintCheck` и `detekt` — зелёные.
|
`ktlintCheck` и `detekt` — зелёные.
|
||||||
|
|
||||||
### 2.2. LDD gate
|
### 2.2. LDD gate
|
||||||
@@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
|
|||||||
./gradlew evidenceCheck
|
./gradlew evidenceCheck
|
||||||
```
|
```
|
||||||
|
|
||||||
Результат: **51/51 claims proven, P0 gaps = 0**.
|
Результат: **57/57 claims proven, P0 gaps = 0**.
|
||||||
Отчёт: `build/reports/evidence/index.md`.
|
Отчёт: `build/reports/evidence/index.md`.
|
||||||
|
|
||||||
Gate устроен так:
|
Gate устроен так:
|
||||||
@@ -306,8 +306,8 @@ optional json fields`.
|
|||||||
|
|
||||||
## 5. Вывод
|
## 5. Вывод
|
||||||
|
|
||||||
Текущая функциональность прошла LDD-контур: **51/51 claims proven, P0 gaps = 0**,
|
Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**,
|
||||||
**348 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
|
**366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
|
||||||
масштабный load зелёные. Это достаточное основание продолжать доработки, но не
|
масштабный load зелёные. Это достаточное основание продолжать доработки, но не
|
||||||
«формальное доказательство всей системы».
|
«формальное доказательство всей системы».
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,11 @@
|
|||||||
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
|
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
|
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
|
||||||
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#public tree contains only global chapters with ALL audience()",
|
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous tree contains only global chapters with ALL audience()",
|
||||||
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
|
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
|
||||||
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
|
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
|
||||||
],
|
],
|
||||||
"limitations": "Legacy-активы без article_assets остаются публичными и вынесены в отдельный риск."
|
"limitations": "Legacy-активы без article_assets закрыты отдельными claims LDD-ASSET-002/003."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "LDD-ACCESS-004",
|
"id": "LDD-ACCESS-004",
|
||||||
@@ -659,16 +659,18 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "LDD-AUTH-005",
|
"id": "LDD-AUTH-005",
|
||||||
"title": "Refresh/logout работают через реальный gateway",
|
"title": "Gateway пропускает refresh/logout без access-JWT",
|
||||||
"criticality": "P0",
|
"criticality": "P0",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"requirement": "docs/16-stage6-report.md, §4",
|
"requirement": "docs/16-stage6-report.md, §4",
|
||||||
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
|
"claim": "Gateway не требует access-JWT для /api/v1/auth/refresh и /api/v1/auth/logout; защищённый маршрут без токена получает 401. Полный цикл refresh/logout через реальный gateway проверяется отдельным P1 runtime-claim.",
|
||||||
"oracle": "runtime.auth_refresh",
|
"oracle": "JwtAuthenticationFilterTest: public paths пропущены, protected path без токена — 401",
|
||||||
"runtime": [
|
"runtime": [],
|
||||||
"runtime.auth_refresh"
|
"limitations": "Unit-тест проверяет маршрутизацию gateway; полный обмен пары и отзыв refresh — LDD-AUTH-008.",
|
||||||
],
|
"evidence": [
|
||||||
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
|
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()",
|
||||||
|
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "LDD-AUTH-006",
|
"id": "LDD-AUTH-006",
|
||||||
@@ -747,6 +749,97 @@
|
|||||||
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
|
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
|
||||||
],
|
],
|
||||||
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
|
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-AUTH-008",
|
||||||
|
"title": "Refresh/logout через реальный gateway",
|
||||||
|
"criticality": "P1",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/16-stage6-report.md, §2.3",
|
||||||
|
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
|
||||||
|
"oracle": "runtime.auth_refresh",
|
||||||
|
"runtime": [
|
||||||
|
"runtime.auth_refresh"
|
||||||
|
],
|
||||||
|
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-ACCESS-010",
|
||||||
|
"title": "Черновики лабораторных скрыты от студентов",
|
||||||
|
"criticality": "P0",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/07-groups-courses.md, §5",
|
||||||
|
"claim": "Студент не видит черновик лабораторной ни в списке, ни в пакетных деталях, ни по прямому UUID; преподаватель и админ видят черновик.",
|
||||||
|
"oracle": "HTTP 200/404 и фильтрация списков",
|
||||||
|
"evidence": [
|
||||||
|
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id()",
|
||||||
|
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access a draft lab of own course()"
|
||||||
|
],
|
||||||
|
"limitations": "Проверены academic API и список; отдельный тест прогресса на сдачу черновика не добавлялся, так как academic отдаёт 404."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-LIFE-004",
|
||||||
|
"title": "Академический отпуск: вход только для чтения",
|
||||||
|
"criticality": "P0",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/07-groups-courses.md, §0.3",
|
||||||
|
"claim": "Студент в ACADEMIC_LEAVE может войти и читать, но не может сдавать лабораторные и начинать/завершать защиту.",
|
||||||
|
"oracle": "readOnly в login/me, ForbiddenException на submit/start/finish",
|
||||||
|
"evidence": [
|
||||||
|
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave login is read only()",
|
||||||
|
"ru.ysu.lms.progress.SubmissionServiceTest#academic leave student cannot submit()",
|
||||||
|
"ru.ysu.lms.progress.DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense()"
|
||||||
|
],
|
||||||
|
"limitations": "Запрет проверяется на progress-service; UI скрывает кнопки, но не является единственной защитой."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-LIFE-005",
|
||||||
|
"title": "Действие «Убрать из группы» удалено",
|
||||||
|
"criticality": "P1",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/14-stage4-report.md, §3",
|
||||||
|
"claim": "В админском UI и API больше нет отдельного действия unenroll; доступны только перевод, академ, отчисление, архив и восстановление.",
|
||||||
|
"oracle": "Отсутствие маршрута/кнопки/метода и отрицательные проверки шаблона",
|
||||||
|
"evidence": [
|
||||||
|
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders group lifecycle buttons by student status()",
|
||||||
|
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()"
|
||||||
|
],
|
||||||
|
"limitations": "Удалённый API-маршрут не проверяется отдельным HTTP-тестом; отсутствие метода компилятор проверяет по коду."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-IMPORT-002",
|
||||||
|
"title": "Импорт не воскрешает неактивных студентов без флага",
|
||||||
|
"criticality": "P1",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/07-groups-courses.md, §9",
|
||||||
|
"claim": "Повторный импорт сохраняет EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE без явного флага restoreInactiveStatuses и сообщает строку в skipped; с флагом активирует и зачисляет.",
|
||||||
|
"oracle": "Статус/зачисление/ImportResult.skipped",
|
||||||
|
"evidence": [
|
||||||
|
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import does not resurrect expelled student without explicit flag()",
|
||||||
|
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import restores expelled student with explicit flag and records import history()",
|
||||||
|
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import preserves academic leave without explicit flag()",
|
||||||
|
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import still updates an active student()",
|
||||||
|
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import form requires explicit restoration flag()",
|
||||||
|
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import result shows skipped inactive rows()"
|
||||||
|
],
|
||||||
|
"limitations": "Флаг восстанавливает статус студента; архивный аккаунт может потребовать отдельного lifecycle-восстановления."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LDD-CONTENT-003",
|
||||||
|
"title": "Глобальные материалы STUDENTS/TEACHERS",
|
||||||
|
"criticality": "P1",
|
||||||
|
"status": "active",
|
||||||
|
"requirement": "docs/07-groups-courses.md, §6",
|
||||||
|
"claim": "Аноним видит только ALL; студент — ALL+STUDENTS; преподаватель — ALL+TEACHERS; прямой доступ к чужой аудитории даёт 404; админ может создать/сменить аудиторию.",
|
||||||
|
"oracle": "Фильтрация дерева/статей по JWT-роли и authoring audience",
|
||||||
|
"evidence": [
|
||||||
|
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global tree is filtered by role audience()",
|
||||||
|
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global article access follows audience and role()",
|
||||||
|
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#authoring creates requested audience and rejects invalid()",
|
||||||
|
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#changing audience immediately changes access()",
|
||||||
|
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
|
||||||
|
],
|
||||||
|
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,35 @@
|
|||||||
package ru.ysu.lms.academic.content
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
/** Аудитория глобальной главы, доступной без авторизации. */
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
|
||||||
|
/** Аудитории глобальных глав; ALL доступна без авторизации. */
|
||||||
const val AUDIENCE_ALL = "ALL"
|
const val AUDIENCE_ALL = "ALL"
|
||||||
const val AUDIENCE_STUDENTS = "STUDENTS"
|
const val AUDIENCE_STUDENTS = "STUDENTS"
|
||||||
const val AUDIENCE_TEACHERS = "TEACHERS"
|
const val AUDIENCE_TEACHERS = "TEACHERS"
|
||||||
|
|
||||||
|
/** Допустимые аудитории глобальной главы. */
|
||||||
|
val CONTENT_AUDIENCES: Set<String> = setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS, AUDIENCE_TEACHERS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аудитории глобальных глав, доступные роли. Аноним видит только ALL,
|
||||||
|
* студент — ALL и STUDENTS, преподаватель — ALL и TEACHERS, администратор — всё.
|
||||||
|
*/
|
||||||
|
fun audiencesForRole(role: String?): Set<String> =
|
||||||
|
when (role) {
|
||||||
|
ROLE_ADMIN -> CONTENT_AUDIENCES
|
||||||
|
ROLE_STUDENT -> setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS)
|
||||||
|
ROLE_TEACHER -> setOf(AUDIENCE_ALL, AUDIENCE_TEACHERS)
|
||||||
|
else -> setOf(AUDIENCE_ALL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нормализует и проверяет аудиторию; `null` остаётся `null`. */
|
||||||
|
fun normalizeAudience(audience: String?): String? {
|
||||||
|
if (audience == null) return null
|
||||||
|
val normalized = audience.trim().uppercase()
|
||||||
|
if (normalized !in CONTENT_AUDIENCES) throw BadRequestException("Недопустимая аудитория: $audience")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ROLE_ADMIN = "ADMIN"
|
||||||
|
private const val ROLE_STUDENT = "STUDENT"
|
||||||
|
private const val ROLE_TEACHER = "TEACHER"
|
||||||
|
|||||||
@@ -61,17 +61,19 @@ class ContentAuthoringService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug
|
* Глобальная глава (`courseId == null`) получает запрошенную аудиторию
|
||||||
* среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию
|
* (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
|
||||||
* и уникальный slug внутри курса.
|
* получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createChapter(
|
fun createChapter(
|
||||||
title: String,
|
title: String,
|
||||||
slug: String?,
|
slug: String?,
|
||||||
courseId: UUID? = null,
|
courseId: UUID? = null,
|
||||||
|
audience: String? = null,
|
||||||
): ChapterTreeNode {
|
): ChapterTreeNode {
|
||||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||||
|
val requestedAudience = normalizeAudience(audience)
|
||||||
val scope =
|
val scope =
|
||||||
if (courseId == null) {
|
if (courseId == null) {
|
||||||
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
|
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
|
||||||
@@ -88,7 +90,7 @@ class ContentAuthoringService(
|
|||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
slug = cleanSlug,
|
slug = cleanSlug,
|
||||||
position = maxPos + 1,
|
position = maxPos + 1,
|
||||||
audience = if (courseId == null) AUDIENCE_ALL else null,
|
audience = if (courseId == null) requestedAudience ?: AUDIENCE_ALL else null,
|
||||||
courseId = courseId,
|
courseId = courseId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -98,10 +100,23 @@ class ContentAuthoringService(
|
|||||||
slug = ch.slug,
|
slug = ch.slug,
|
||||||
position = ch.position,
|
position = ch.position,
|
||||||
published = ch.published,
|
published = ch.published,
|
||||||
|
audience = ch.audience,
|
||||||
sections = emptyList(),
|
sections = emptyList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Меняет аудиторию только у глобальной главы; у курсовой её нет. */
|
||||||
|
@Transactional
|
||||||
|
fun updateChapterAudience(
|
||||||
|
chapterId: UUID,
|
||||||
|
audience: String,
|
||||||
|
) {
|
||||||
|
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
if (ch.courseId != null) throw BadRequestException("Аудитория задаётся только для глобальных глав")
|
||||||
|
ch.audience = normalizeAudience(audience)
|
||||||
|
chapters.save(ch)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun renameChapter(
|
fun renameChapter(
|
||||||
chapterId: UUID,
|
chapterId: UUID,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ data class ChapterTreeNode(
|
|||||||
val slug: String,
|
val slug: String,
|
||||||
val position: Int,
|
val position: Int,
|
||||||
val published: Boolean = true,
|
val published: Boolean = true,
|
||||||
|
val audience: String? = null,
|
||||||
val sections: List<SectionTreeNode>,
|
val sections: List<SectionTreeNode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,19 +116,28 @@ class ContentService(
|
|||||||
private val importer: ContentImporter,
|
private val importer: ContentImporter,
|
||||||
private val assetStorage: AssetStorage,
|
private val assetStorage: AssetStorage,
|
||||||
) {
|
) {
|
||||||
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
/**
|
||||||
|
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
|
||||||
|
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
|
||||||
|
* администратор — все аудитории. Курсовые главы здесь не показываются.
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun tree(includeHidden: Boolean = false): ContentTree =
|
fun tree(
|
||||||
buildTree(
|
includeHidden: Boolean = false,
|
||||||
|
role: String? = null,
|
||||||
|
): ContentTree {
|
||||||
|
val allowedAudiences = audiencesForRole(role)
|
||||||
|
return buildTree(
|
||||||
chapters
|
chapters
|
||||||
.findAllByOrderByPositionAscIdAsc()
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
.filter {
|
.filter {
|
||||||
it.courseId == null &&
|
it.courseId == null &&
|
||||||
it.audience == AUDIENCE_ALL &&
|
it.audience in allowedAudiences &&
|
||||||
(includeHidden || it.published)
|
(includeHidden || it.published)
|
||||||
},
|
},
|
||||||
includeHidden = includeHidden,
|
includeHidden = includeHidden,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
|
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -150,9 +160,10 @@ class ContentService(
|
|||||||
sectionSlug: String,
|
sectionSlug: String,
|
||||||
articleSlug: String,
|
articleSlug: String,
|
||||||
includeHidden: Boolean = false,
|
includeHidden: Boolean = false,
|
||||||
|
role: String? = null,
|
||||||
): ContentArticleView {
|
): ContentArticleView {
|
||||||
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
|
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
|
||||||
requirePublicChapter(ch, includeHidden)
|
requirePublicChapter(ch, includeHidden, role)
|
||||||
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +266,10 @@ class ContentService(
|
|||||||
private fun requirePublicChapter(
|
private fun requirePublicChapter(
|
||||||
ch: ContentChapter,
|
ch: ContentChapter,
|
||||||
includeHidden: Boolean,
|
includeHidden: Boolean,
|
||||||
|
role: String?,
|
||||||
) {
|
) {
|
||||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||||
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
|
if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildTree(
|
private fun buildTree(
|
||||||
@@ -284,6 +296,7 @@ class ContentService(
|
|||||||
slug = ch.slug,
|
slug = ch.slug,
|
||||||
position = ch.position,
|
position = ch.position,
|
||||||
published = ch.published,
|
published = ch.published,
|
||||||
|
audience = ch.audience,
|
||||||
sections =
|
sections =
|
||||||
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||||
SectionTreeNode(
|
SectionTreeNode(
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class ContentAdminController(
|
|||||||
data class ChapterCreatePayload(
|
data class ChapterCreatePayload(
|
||||||
val title: String,
|
val title: String,
|
||||||
val slug: String? = null,
|
val slug: String? = null,
|
||||||
|
val audience: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChapterAudiencePayload(
|
||||||
|
val audience: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SectionCreatePayload(
|
data class SectionCreatePayload(
|
||||||
@@ -159,13 +164,29 @@ class ContentAdminController(
|
|||||||
fun createChapter(
|
fun createChapter(
|
||||||
@RequestBody body: ChapterCreatePayload,
|
@RequestBody body: ChapterCreatePayload,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
) = authoring.createChapter(body.title, body.slug).also {
|
) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
|
||||||
audit.record(
|
audit.record(
|
||||||
jwt.subject,
|
jwt.subject,
|
||||||
"CONTENT_CHAPTER_CREATE",
|
"CONTENT_CHAPTER_CREATE",
|
||||||
ENTITY_CHAPTER,
|
ENTITY_CHAPTER,
|
||||||
it.id,
|
it.id,
|
||||||
details = "title=${body.title}",
|
details = "title=${body.title}, audience=${it.audience}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/chapters/{id}/audience")
|
||||||
|
fun chapterAudience(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody body: ChapterAudiencePayload,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
) {
|
||||||
|
authoring.updateChapterAudience(id, body.audience)
|
||||||
|
audit.record(
|
||||||
|
jwt.subject,
|
||||||
|
"CONTENT_CHAPTER_AUDIENCE",
|
||||||
|
ENTITY_CHAPTER,
|
||||||
|
id.toString(),
|
||||||
|
details = "audience=${body.audience}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import java.util.UUID
|
|||||||
* Публичное чтение учебных материалов.
|
* Публичное чтение учебных материалов.
|
||||||
*
|
*
|
||||||
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
|
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
|
||||||
|
* С JWT дополнительно открываются STUDENTS для студента и TEACHERS для преподавателя;
|
||||||
|
* администратор видит все аудитории. Курсовые главы отдаются только курсовыми эндпоинтами.
|
||||||
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
|
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
|
||||||
* их может скачать только админ для очистки. Привязанные активы наследуют
|
* их может скачать только админ для очистки. Привязанные активы наследуют
|
||||||
* видимость статьи, раздела и главы.
|
* видимость статьи, раздела и главы.
|
||||||
@@ -37,14 +39,20 @@ class ContentPublicController(
|
|||||||
private val accessService: CourseAccessService,
|
private val accessService: CourseAccessService,
|
||||||
) {
|
) {
|
||||||
@GetMapping("/tree")
|
@GetMapping("/tree")
|
||||||
fun tree() = contentService.tree()
|
fun tree(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt?,
|
||||||
|
) = contentService.tree(role = jwt?.getClaimAsString("role"))
|
||||||
|
|
||||||
@GetMapping("/{chapter}/{section}/{article}")
|
@GetMapping("/{chapter}/{section}/{article}")
|
||||||
fun article(
|
fun article(
|
||||||
@PathVariable chapter: String,
|
@PathVariable chapter: String,
|
||||||
@PathVariable section: String,
|
@PathVariable section: String,
|
||||||
@PathVariable article: String,
|
@PathVariable article: String,
|
||||||
): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto()
|
@AuthenticationPrincipal jwt: Jwt?,
|
||||||
|
): ContentArticleViewDto =
|
||||||
|
contentService
|
||||||
|
.findArticle(chapter, section, article, role = jwt?.getClaimAsString("role"))
|
||||||
|
.toDto()
|
||||||
|
|
||||||
data class ContentArticleViewDto(
|
data class ContentArticleViewDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -46,12 +46,13 @@ class LabController(
|
|||||||
@RequestParam(required = false) courseId: UUID?,
|
@RequestParam(required = false) courseId: UUID?,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): List<LabSummary> {
|
): List<LabSummary> {
|
||||||
|
val student = accessService.isStudent(jwt)
|
||||||
if (courseId != null) {
|
if (courseId != null) {
|
||||||
accessService.requireCourseAccess(courseId, jwt)
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
return labService.list(courseId)
|
return labService.list(courseId).filter { !student || it.status == "published" }
|
||||||
}
|
}
|
||||||
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
|
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
|
||||||
return labService.list(accessible)
|
return labService.list(accessible).filter { !student || it.status == "published" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
|
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
|
||||||
@@ -61,7 +62,8 @@ class LabController(
|
|||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): List<LabDetail> {
|
): List<LabDetail> {
|
||||||
accessService.requireCourseAccess(courseId, jwt)
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
return labService.details(courseId)
|
val details = labService.details(courseId)
|
||||||
|
return if (accessService.isStudent(jwt)) details.filter { it.status == "published" } else details
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ class CourseAccessService(
|
|||||||
jwt: Jwt,
|
jwt: Jwt,
|
||||||
) {
|
) {
|
||||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
|
if (role(jwt) == ROLE_STUDENT && lab.status != "published") {
|
||||||
|
// Черновик не существует для студента: не раскрываем его даже прямым UUID.
|
||||||
|
throw NotFoundException("Лабораторная не найдена")
|
||||||
|
}
|
||||||
val courseId = lab.course?.id
|
val courseId = lab.course?.id
|
||||||
if (courseId == null) {
|
if (courseId == null) {
|
||||||
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
|
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
|
||||||
@@ -84,6 +88,8 @@ class CourseAccessService(
|
|||||||
|
|
||||||
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
|
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
|
||||||
|
|
||||||
|
fun isStudent(jwt: Jwt): Boolean = role(jwt) == ROLE_STUDENT
|
||||||
|
|
||||||
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
|
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
|
||||||
if (courseIds.isEmpty()) return emptySet()
|
if (courseIds.isEmpty()) return emptySet()
|
||||||
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
|
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class CourseContentIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `public tree contains only global chapters with ALL audience`() {
|
fun `anonymous tree contains only global chapters with ALL audience`() {
|
||||||
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
|
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
|
||||||
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
|
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
|
||||||
val course = newCourse()
|
val course = newCourse()
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.hamcrest.Matchers.containsInAnyOrder
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry
|
||||||
|
import org.springframework.test.context.DynamicPropertySource
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||||
|
import org.springframework.test.web.servlet.request.RequestPostProcessor
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.context.WebApplicationContext
|
||||||
|
import org.testcontainers.junit.jupiter.Container
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||||
|
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseService
|
||||||
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аудитория глобальных глав: аноним видит только ALL, студент — ALL/STUDENTS,
|
||||||
|
* преподаватель — ALL/TEACHERS, админ — всё. Курсовые главы аудитории не имеют.
|
||||||
|
*/
|
||||||
|
@Testcontainers
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class GlobalAudienceAccessIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
@JvmStatic
|
||||||
|
val postgres = PostgreSQLContainer("postgres:18")
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@DynamicPropertySource
|
||||||
|
fun assetsDir(registry: DynamicPropertyRegistry) {
|
||||||
|
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var context: WebApplicationContext
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var authoring: ContentAuthoringService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var courseService: CourseService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var chapters: ContentChapterRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var sections: ContentSectionRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var articles: ContentArticleRepository
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc by lazy {
|
||||||
|
MockMvcBuilders
|
||||||
|
.webAppContextSetup(context)
|
||||||
|
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `global tree is filtered by role audience`() {
|
||||||
|
val all = seedMaterial(AUDIENCE_ALL)
|
||||||
|
val students = seedMaterial(AUDIENCE_STUDENTS)
|
||||||
|
val teachers = seedMaterial(AUDIENCE_TEACHERS)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/tree"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug)))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/tree").with(jwtFor("STUDENT")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, students.chapter.slug)),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/tree").with(jwtFor("TEACHER")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, teachers.chapter.slug)),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/tree/all").with(jwtFor("ADMIN")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(
|
||||||
|
"$.chapters[*].slug",
|
||||||
|
containsInAnyOrder(all.chapter.slug, students.chapter.slug, teachers.chapter.slug),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `global article access follows audience and role`() {
|
||||||
|
val students = seedMaterial(AUDIENCE_STUDENTS)
|
||||||
|
val teachers = seedMaterial(AUDIENCE_TEACHERS)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(students)))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(students)).with(jwtFor("STUDENT")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.title").value(students.article.title))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(students)).with(jwtFor("TEACHER")))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(teachers)).with(jwtFor("TEACHER")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.title").value(teachers.article.title))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(teachers)).with(jwtFor("STUDENT")))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authoring creates requested audience and rejects invalid`() {
|
||||||
|
val node = authoring.createChapter("Студенческая глава ${UUID.randomUUID()}", null, audience = AUDIENCE_STUDENTS)
|
||||||
|
assertThat(chapters.findById(UUID.fromString(node.id)).orElseThrow().audience).isEqualTo(AUDIENCE_STUDENTS)
|
||||||
|
|
||||||
|
val defaultNode = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", null)
|
||||||
|
assertThat(chapters.findById(UUID.fromString(defaultNode.id)).orElseThrow().audience).isEqualTo(AUDIENCE_ALL)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
post("/api/v1/content/chapters")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"title":"Неверная аудитория","audience":"EVERYONE"}""")
|
||||||
|
.with(jwtFor("ADMIN")),
|
||||||
|
).andExpect(status().isBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course chapter never gets audience and cannot change it`() {
|
||||||
|
val courseId =
|
||||||
|
UUID.fromString(
|
||||||
|
courseService
|
||||||
|
.create(CourseCreateRequest("Курс аудитории ${UUID.randomUUID()}"), UUID.randomUUID())
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
val node =
|
||||||
|
authoring.createChapter(
|
||||||
|
"Курсовая глава ${UUID.randomUUID()}",
|
||||||
|
null,
|
||||||
|
courseId,
|
||||||
|
audience = AUDIENCE_STUDENTS,
|
||||||
|
)
|
||||||
|
val chapterId = UUID.fromString(node.id)
|
||||||
|
|
||||||
|
assertThat(chapters.findById(chapterId).orElseThrow().audience).isNull()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
put("/api/v1/content/chapters/{id}/audience", chapterId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"audience":"TEACHERS"}""")
|
||||||
|
.with(jwtFor("ADMIN")),
|
||||||
|
).andExpect(status().isBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing audience immediately changes access`() {
|
||||||
|
val material = seedMaterial(AUDIENCE_STUDENTS)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
put("/api/v1/content/chapters/{id}/audience", material.chapter.id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"audience":"TEACHERS"}""")
|
||||||
|
.with(jwtFor("ADMIN")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedMaterial(audience: String): SeededMaterial {
|
||||||
|
val chapter =
|
||||||
|
chapters.saveAndFlush(
|
||||||
|
ContentChapter(
|
||||||
|
title = "Глава ${UUID.randomUUID()}",
|
||||||
|
slug = "chapter-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = true,
|
||||||
|
audience = audience,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val section =
|
||||||
|
sections.saveAndFlush(
|
||||||
|
ContentSection(
|
||||||
|
chapterId = chapter.id,
|
||||||
|
title = "Раздел ${UUID.randomUUID()}",
|
||||||
|
slug = "section-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val article =
|
||||||
|
articles.saveAndFlush(
|
||||||
|
ContentArticle(
|
||||||
|
sectionId = section.id,
|
||||||
|
title = "Статья ${UUID.randomUUID()}",
|
||||||
|
slug = "article-${UUID.randomUUID()}",
|
||||||
|
bodyMd = "Текст",
|
||||||
|
position = 1,
|
||||||
|
published = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SeededMaterial(chapter, section, article)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun articlePath(material: SeededMaterial): String =
|
||||||
|
"/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"
|
||||||
|
|
||||||
|
private fun jwtFor(role: String): RequestPostProcessor =
|
||||||
|
jwt()
|
||||||
|
.authorities(SimpleGrantedAuthority("ROLE_$role"))
|
||||||
|
.jwt { it.subject(UUID.randomUUID().toString()).claim("role", role) }
|
||||||
|
|
||||||
|
private data class SeededMaterial(
|
||||||
|
val chapter: ContentChapter,
|
||||||
|
val section: ContentSection,
|
||||||
|
val article: ContentArticle,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ class CourseAccessServiceMutationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lab without course is admin only`() {
|
fun `lab without course is admin only`() {
|
||||||
val lab = Lab(number = 1, title = "Без курса")
|
val lab = Lab(number = 1, title = "Без курса", status = "published")
|
||||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
|
||||||
assertThatCode {
|
assertThatCode {
|
||||||
@@ -143,7 +143,7 @@ class CourseAccessServiceMutationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `lab access follows course`() {
|
fun `lab access follows course`() {
|
||||||
val active = course(Course.STATUS_ACTIVE)
|
val active = course(Course.STATUS_ACTIVE)
|
||||||
val lab = Lab(number = 1, title = "Лабораторная", course = active)
|
val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
|
||||||
val groupId = UUID.randomUUID()
|
val groupId = UUID.randomUUID()
|
||||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
|
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
|
||||||
@@ -210,7 +210,7 @@ class CourseAccessServiceMutationTest {
|
|||||||
val active = course(Course.STATUS_ACTIVE)
|
val active = course(Course.STATUS_ACTIVE)
|
||||||
val foreign = course(Course.STATUS_ACTIVE)
|
val foreign = course(Course.STATUS_ACTIVE)
|
||||||
val teacherId = UUID.randomUUID()
|
val teacherId = UUID.randomUUID()
|
||||||
val lab = Lab(number = 1, title = "Лабораторная", course = foreign)
|
val lab = Lab(number = 1, title = "Лабораторная", course = foreign, status = "published")
|
||||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
listOf(CourseTeacher(course = active, accountId = teacherId))
|
listOf(CourseTeacher(course = active, accountId = teacherId))
|
||||||
@@ -219,6 +219,22 @@ class CourseAccessServiceMutationTest {
|
|||||||
.isInstanceOf(ForbiddenException::class.java)
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `draft lab is hidden from student`() {
|
||||||
|
val active = course(Course.STATUS_ACTIVE)
|
||||||
|
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
|
||||||
|
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
|
||||||
|
listOf(CourseGroup(course = active, groupId = groupId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(active)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `missing lab is not found`() {
|
fun `missing lab is not found`() {
|
||||||
every { labs.findById(any()) } returns Optional.empty()
|
every { labs.findById(any()) } returns Optional.empty()
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class CourseAccessServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `lab access follows its course`() {
|
fun `lab access follows its course`() {
|
||||||
val active = course()
|
val active = course()
|
||||||
val lab = Lab(number = 1, title = "Лабораторная", course = active)
|
val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
|
||||||
val groupId = UUID.randomUUID()
|
val groupId = UUID.randomUUID()
|
||||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
stubStudentGroup(active, groupId)
|
stubStudentGroup(active, groupId)
|
||||||
@@ -211,7 +211,7 @@ class CourseAccessServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lab without course is admin only`() {
|
fun `lab without course is admin only`() {
|
||||||
val lab = Lab(number = 1, title = "Без курса")
|
val lab = Lab(number = 1, title = "Без курса", status = "published")
|
||||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
|
||||||
assertThatCode {
|
assertThatCode {
|
||||||
@@ -222,6 +222,19 @@ class CourseAccessServiceTest {
|
|||||||
}.isInstanceOf(ForbiddenException::class.java)
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student cannot access a draft lab of own course`() {
|
||||||
|
val active = course()
|
||||||
|
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
stubStudentGroup(active, groupId)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(NotFoundException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `missing lab is not found`() {
|
fun `missing lab is not found`() {
|
||||||
every { labs.findById(any()) } returns Optional.empty()
|
every { labs.findById(any()) } returns Optional.empty()
|
||||||
|
|||||||
@@ -164,6 +164,34 @@ class LabDetailsIntegrationTest {
|
|||||||
).andExpect(status().isForbidden)
|
).andExpect(status().isForbidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student sees published labs but not drafts in list details or by id`() {
|
||||||
|
val courseId = newCourse()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
|
||||||
|
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
|
||||||
|
val publishedId = createLab(courseId, 1, "Опубликованная", emptyList(), emptyList(), status = "published")
|
||||||
|
val draftId = createLab(courseId, 2, "Черновик", emptyList(), emptyList(), status = "draft")
|
||||||
|
val student = jwtFor(UUID.randomUUID(), "STUDENT")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/labs").param("courseId", courseId.toString()).with(student))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
|
||||||
|
.andExpect(jsonPath("$[0].status").value("published"))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/labs/details").param("courseId", courseId.toString()).with(student))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/labs/{id}", draftId).with(student))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `course without labs returns an empty details list`() {
|
fun `course without labs returns an empty details list`() {
|
||||||
val courseId = newCourse()
|
val courseId = newCourse()
|
||||||
@@ -228,13 +256,15 @@ class LabDetailsIntegrationTest {
|
|||||||
title: String,
|
title: String,
|
||||||
levels: List<LevelSaveRequest>,
|
levels: List<LevelSaveRequest>,
|
||||||
variants: List<SeededVariant>,
|
variants: List<SeededVariant>,
|
||||||
) {
|
status: String = "published",
|
||||||
|
): UUID {
|
||||||
val created =
|
val created =
|
||||||
labService.create(
|
labService.create(
|
||||||
LabCreateRequest(
|
LabCreateRequest(
|
||||||
number = number,
|
number = number,
|
||||||
title = title,
|
title = title,
|
||||||
courseId = courseId,
|
courseId = courseId,
|
||||||
|
status = status,
|
||||||
levels = levels,
|
levels = levels,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -251,6 +281,7 @@ class LabDetailsIntegrationTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return labId
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jwtFor(
|
private fun jwtFor(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ data class AuthResponse(
|
|||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
val refreshToken: String? = null,
|
val refreshToken: String? = null,
|
||||||
val accessTokenExpiresIn: Long = 0,
|
val accessTokenExpiresIn: Long = 0,
|
||||||
|
val readOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MeResponse(
|
data class MeResponse(
|
||||||
@@ -50,4 +51,5 @@ data class MeResponse(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
val active: Boolean = true,
|
val active: Boolean = true,
|
||||||
|
val readOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ class AuthService(
|
|||||||
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Академический отпуск: вход разрешён, но писать данные нельзя. */
|
||||||
|
private fun readOnly(account: Account): Boolean =
|
||||||
|
account.role == LmsRole.STUDENT &&
|
||||||
|
students.findById(account.id).map { it.status == StudentStatus.ACADEMIC_LEAVE }.orElse(false)
|
||||||
|
|
||||||
private fun ensureLoginAllowed(account: Account) {
|
private fun ensureLoginAllowed(account: Account) {
|
||||||
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||||
}
|
}
|
||||||
@@ -129,6 +134,7 @@ class AuthService(
|
|||||||
name = displayName(account),
|
name = displayName(account),
|
||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
active = loginDeniedReason(account) == null,
|
active = loginDeniedReason(account) == null,
|
||||||
|
readOnly = readOnly(account),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +195,7 @@ class AuthService(
|
|||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
refreshToken = refreshToken,
|
refreshToken = refreshToken,
|
||||||
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
|
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
|
||||||
|
readOnly = readOnly(account),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun issueToken(
|
private fun issueToken(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -37,24 +36,62 @@ class GroupController(
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
fun create(
|
fun create(
|
||||||
@Valid @RequestBody body: CreateGroupRequest,
|
@Valid @RequestBody body: CreateGroupRequest,
|
||||||
): GroupSummary = groupService.create(body)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): GroupSummary =
|
||||||
|
groupService.create(body).also {
|
||||||
|
auditService.record(
|
||||||
|
jwt.toAuditRequest(
|
||||||
|
action = "GROUP_CREATE",
|
||||||
|
entityType = "GROUP",
|
||||||
|
entityId = it.id,
|
||||||
|
details = "name=${it.name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/students")
|
@PostMapping("/{id}/students")
|
||||||
fun createStudent(
|
fun createStudent(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@Valid @RequestBody body: CreateStudentRequest,
|
@Valid @RequestBody body: CreateStudentRequest,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
|
): Credential =
|
||||||
|
groupService.createStudent(id, body, UUID.fromString(jwt.subject)).also {
|
||||||
|
auditService.record(
|
||||||
|
jwt.toAuditRequest(
|
||||||
|
action = "STUDENT_CREATE",
|
||||||
|
entityType = "STUDENT",
|
||||||
|
entityId = it.id,
|
||||||
|
studentId = it.id,
|
||||||
|
details = "group=$id, studentNo=${it.studentNo}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): ImportResult {
|
): ImportResult {
|
||||||
if (file.isEmpty) {
|
if (file.isEmpty) {
|
||||||
throw BadRequestException("Выберите файл со списком группы")
|
throw BadRequestException("Выберите файл со списком группы")
|
||||||
}
|
}
|
||||||
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
|
val result =
|
||||||
|
importService.import(
|
||||||
|
file.originalFilename ?: "group.xlsx",
|
||||||
|
file.bytes,
|
||||||
|
UUID.fromString(jwt.subject),
|
||||||
|
restoreInactiveStatuses,
|
||||||
|
)
|
||||||
|
auditService.record(
|
||||||
|
jwt.toAuditRequest(
|
||||||
|
action = "GROUP_IMPORT",
|
||||||
|
entityType = "GROUP",
|
||||||
|
entityId = result.groupId,
|
||||||
|
details = "added=${result.added.size}, updated=${result.updated.size}, skipped=${result.skipped.size}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||||
@@ -69,15 +106,18 @@ class GroupController(
|
|||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@PathVariable studentId: UUID,
|
@PathVariable studentId: UUID,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
|
): GroupDetail =
|
||||||
|
groupService.enroll(id, studentId, UUID.fromString(jwt.subject)).also {
|
||||||
/** Отчислить студента из группы. */
|
auditService.record(
|
||||||
@DeleteMapping("/{id}/students/{studentId}")
|
jwt.toAuditRequest(
|
||||||
fun unenroll(
|
action = "GROUP_ENROLL",
|
||||||
@PathVariable id: UUID,
|
entityType = "STUDENT",
|
||||||
@PathVariable studentId: UUID,
|
entityId = studentId.toString(),
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
studentId = studentId.toString(),
|
||||||
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
|
details = "group=$id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import ru.ysu.lms.common.ConflictException
|
import ru.ysu.lms.common.ConflictException
|
||||||
import ru.ysu.lms.identity.auth.Passwords
|
import ru.ysu.lms.identity.auth.Passwords
|
||||||
import ru.ysu.lms.identity.domain.Account
|
import ru.ysu.lms.identity.domain.Account
|
||||||
|
import ru.ysu.lms.identity.domain.AccountStatus
|
||||||
import ru.ysu.lms.identity.domain.Enrollment
|
import ru.ysu.lms.identity.domain.Enrollment
|
||||||
import ru.ysu.lms.identity.domain.GroupEntity
|
import ru.ysu.lms.identity.domain.GroupEntity
|
||||||
import ru.ysu.lms.identity.domain.GroupStatus
|
import ru.ysu.lms.identity.domain.GroupStatus
|
||||||
@@ -37,6 +38,7 @@ class GroupImportService(
|
|||||||
filename: String,
|
filename: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
actorAccountId: UUID?,
|
actorAccountId: UUID?,
|
||||||
|
restoreInactiveStatuses: Boolean = false,
|
||||||
): ImportResult {
|
): ImportResult {
|
||||||
val parsed = parser.parse(filename, bytes)
|
val parsed = parser.parse(filename, bytes)
|
||||||
if (parsed.errors.isNotEmpty()) {
|
if (parsed.errors.isNotEmpty()) {
|
||||||
@@ -56,10 +58,22 @@ class GroupImportService(
|
|||||||
val group = findOrCreateGroup(cohort)
|
val group = findOrCreateGroup(cohort)
|
||||||
val added = mutableListOf<Credential>()
|
val added = mutableListOf<Credential>()
|
||||||
val updated = mutableListOf<String>()
|
val updated = mutableListOf<String>()
|
||||||
|
val skipped = mutableListOf<ImportError>()
|
||||||
|
|
||||||
parsed.students.forEach { parsedStudent ->
|
parsed.students.forEach { parsedStudent ->
|
||||||
val student = upsertStudent(parsedStudent, cohort, added, updated)
|
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
|
||||||
moveToGroup(student, group, actorAccountId)
|
if (student.status == StudentStatus.ACTIVE) {
|
||||||
|
moveToGroup(student, group, actorAccountId)
|
||||||
|
} else {
|
||||||
|
skipped +=
|
||||||
|
ImportError(
|
||||||
|
row = parsedStudent.row,
|
||||||
|
message =
|
||||||
|
"Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
|
||||||
|
"статус и группа не изменены. Для восстановления включите явное восстановление " +
|
||||||
|
"отчисленных/выпущенных/архивных/академических студентов.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
applied = true,
|
applied = true,
|
||||||
@@ -67,6 +81,7 @@ class GroupImportService(
|
|||||||
groupName = group.name,
|
groupName = group.name,
|
||||||
added = added,
|
added = added,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
|
skipped = skipped,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +118,7 @@ class GroupImportService(
|
|||||||
cohort: GroupCohort,
|
cohort: GroupCohort,
|
||||||
added: MutableList<Credential>,
|
added: MutableList<Credential>,
|
||||||
updated: MutableList<String>,
|
updated: MutableList<String>,
|
||||||
|
restoreInactiveStatuses: Boolean,
|
||||||
): Student {
|
): Student {
|
||||||
val existing = students.findByStudentNo(parsed.studentNo)
|
val existing = students.findByStudentNo(parsed.studentNo)
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -111,8 +127,15 @@ class GroupImportService(
|
|||||||
existing.middleName = parsed.middleName
|
existing.middleName = parsed.middleName
|
||||||
existing.basis = parsed.basis
|
existing.basis = parsed.basis
|
||||||
existing.note = parsed.note
|
existing.note = parsed.note
|
||||||
existing.status = StudentStatus.ACTIVE
|
if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
|
||||||
existing.enrollmentYear = cohort.enrollmentYear
|
existing.status = StudentStatus.ACTIVE
|
||||||
|
existing.enrollmentYear = cohort.enrollmentYear
|
||||||
|
existing.account?.takeIf { it.status == AccountStatus.ARCHIVED }?.let { account ->
|
||||||
|
account.status = AccountStatus.ACTIVE
|
||||||
|
account.archivedAt = null
|
||||||
|
accounts.save(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
students.save(existing)
|
students.save(existing)
|
||||||
updated += parsed.studentNo
|
updated += parsed.studentNo
|
||||||
return existing
|
return existing
|
||||||
|
|||||||
@@ -172,26 +172,6 @@ class GroupService(
|
|||||||
return detail(groupId)
|
return detail(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Убрать студента из группы; аккаунт и данные сохраняются. */
|
|
||||||
@Transactional
|
|
||||||
fun unenroll(
|
|
||||||
groupId: UUID,
|
|
||||||
studentId: UUID,
|
|
||||||
actorAccountId: UUID? = null,
|
|
||||||
): GroupDetail {
|
|
||||||
val enrollment =
|
|
||||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
|
||||||
?: throw NotFoundException("Студент не входит в эту группу")
|
|
||||||
enrollments.delete(enrollment)
|
|
||||||
val student = students.findById(studentId).orElse(null)
|
|
||||||
if (student != null) {
|
|
||||||
student.status = StudentStatus.EXPELLED
|
|
||||||
students.save(student)
|
|
||||||
}
|
|
||||||
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
|
|
||||||
return detail(groupId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ data class ImportResult(
|
|||||||
val groupName: String? = null,
|
val groupName: String? = null,
|
||||||
val added: List<Credential> = emptyList(),
|
val added: List<Credential> = emptyList(),
|
||||||
val updated: List<String> = emptyList(),
|
val updated: List<String> = emptyList(),
|
||||||
|
val skipped: List<ImportError> = emptyList(),
|
||||||
val errors: List<ImportError> = emptyList(),
|
val errors: List<ImportError> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ data class ParsedStudent(
|
|||||||
val middleName: String,
|
val middleName: String,
|
||||||
val basis: String,
|
val basis: String,
|
||||||
val note: String,
|
val note: String,
|
||||||
|
val row: Int = 0,
|
||||||
) {
|
) {
|
||||||
val fullName: String
|
val fullName: String
|
||||||
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
||||||
@@ -300,6 +301,7 @@ class StudentImportParser {
|
|||||||
middleName = middleName,
|
middleName = middleName,
|
||||||
basis = cell(row, columns, "basis"),
|
basis = cell(row, columns, "basis"),
|
||||||
note = cell(row, columns, "note"),
|
note = cell(row, columns, "note"),
|
||||||
|
row = rowNumber,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class GroupServiceIntegrationTest {
|
|||||||
|
|
||||||
@Autowired lateinit var importService: GroupImportService
|
@Autowired lateinit var importService: GroupImportService
|
||||||
|
|
||||||
|
@Autowired lateinit var lifecycleService: StudentLifecycleService
|
||||||
|
|
||||||
@Autowired lateinit var authService: AuthService
|
@Autowired lateinit var authService: AuthService
|
||||||
|
|
||||||
@Autowired lateinit var groups: GroupRepository
|
@Autowired lateinit var groups: GroupRepository
|
||||||
@@ -119,7 +121,7 @@ class GroupServiceIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `transfer and expulsion keep history`() {
|
fun `transfer keeps history`() {
|
||||||
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
|
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
|
||||||
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
|
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
|
||||||
val imported = importService.import("group.csv", csv(validRows()), null)
|
val imported = importService.import("group.csv", csv(validRows()), null)
|
||||||
@@ -137,9 +139,6 @@ class GroupServiceIntegrationTest {
|
|||||||
)
|
)
|
||||||
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||||
|
|
||||||
groupService.unenroll(UUID.fromString(second.id), studentId, null)
|
|
||||||
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
|
|
||||||
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
|
||||||
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
|
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
|
||||||
assertTrue(imported.applied)
|
assertTrue(imported.applied)
|
||||||
}
|
}
|
||||||
@@ -155,12 +154,118 @@ class GroupServiceIntegrationTest {
|
|||||||
)
|
)
|
||||||
authService.login(LoginRequest("9001", credential.password))
|
authService.login(LoginRequest("9001", credential.password))
|
||||||
|
|
||||||
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
|
lifecycleService.expel(UUID.fromString(credential.id), null, null)
|
||||||
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
|
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
|
||||||
|
|
||||||
assertTrue(error is UnauthorizedException)
|
assertTrue(error is UnauthorizedException)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `import does not resurrect expelled student without explicit flag`() {
|
||||||
|
importService.import("group.csv", csv(validRows()), null)
|
||||||
|
val student = students.findByStudentNo("2001")!!
|
||||||
|
val studentId = student.accountId!!
|
||||||
|
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
|
||||||
|
student.status = StudentStatus.EXPELLED
|
||||||
|
students.saveAndFlush(student)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
importService.import(
|
||||||
|
"group.csv",
|
||||||
|
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val preserved = students.findById(studentId).orElseThrow()
|
||||||
|
assertTrue(result.applied)
|
||||||
|
assertEquals(StudentStatus.EXPELLED, preserved.status)
|
||||||
|
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||||
|
assertEquals(2025.toShort(), preserved.enrollmentYear)
|
||||||
|
assertEquals("контракт", preserved.basis)
|
||||||
|
assertEquals(1, result.skipped.size)
|
||||||
|
val skipped = result.skipped.single()
|
||||||
|
assertEquals(2, skipped.row)
|
||||||
|
assertTrue(skipped.message.contains("EXPELLED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `import restores expelled student with explicit flag and records import history`() {
|
||||||
|
importService.import("group.csv", csv(validRows()), null)
|
||||||
|
val student = students.findByStudentNo("2001")!!
|
||||||
|
val studentId = student.accountId!!
|
||||||
|
student.status = StudentStatus.EXPELLED
|
||||||
|
students.saveAndFlush(student)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
importService.import(
|
||||||
|
"group.csv",
|
||||||
|
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||||
|
null,
|
||||||
|
restoreInactiveStatuses = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val restored = students.findById(studentId).orElseThrow()
|
||||||
|
val target = groups.findByName("ПИ-22БО")!!
|
||||||
|
assertTrue(result.applied)
|
||||||
|
assertTrue(result.skipped.isEmpty())
|
||||||
|
assertEquals(StudentStatus.ACTIVE, restored.status)
|
||||||
|
assertEquals(2024.toShort(), restored.enrollmentYear)
|
||||||
|
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||||
|
assertEquals(MembershipAction.IMPORT, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `import preserves academic leave without explicit flag`() {
|
||||||
|
importService.import("group.csv", csv(validRows()), null)
|
||||||
|
val student = students.findByStudentNo("2001")!!
|
||||||
|
val studentId = student.accountId!!
|
||||||
|
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
|
||||||
|
student.status = StudentStatus.ACADEMIC_LEAVE
|
||||||
|
students.saveAndFlush(student)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
importService.import(
|
||||||
|
"group.csv",
|
||||||
|
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val preserved = students.findById(studentId).orElseThrow()
|
||||||
|
assertTrue(result.applied)
|
||||||
|
assertEquals(StudentStatus.ACADEMIC_LEAVE, preserved.status)
|
||||||
|
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||||
|
assertEquals(2025.toShort(), preserved.enrollmentYear)
|
||||||
|
assertEquals(1, result.skipped.size)
|
||||||
|
val skipped = result.skipped.single()
|
||||||
|
assertEquals(2, skipped.row)
|
||||||
|
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `import still updates an active student`() {
|
||||||
|
importService.import("group.csv", csv(validRows()), null)
|
||||||
|
val student = students.findByStudentNo("2001")!!
|
||||||
|
val studentId = student.accountId!!
|
||||||
|
|
||||||
|
val result =
|
||||||
|
importService.import(
|
||||||
|
"group.csv",
|
||||||
|
csv("ПИ;2;2;БО;2024;Иванов Иван Иванович;2001;контракт"),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val updated = students.findById(studentId).orElseThrow()
|
||||||
|
val target = groups.findByName("ПИ-22БО")!!
|
||||||
|
assertTrue(result.applied)
|
||||||
|
assertTrue(result.skipped.isEmpty())
|
||||||
|
assertTrue(result.updated.contains("2001"))
|
||||||
|
assertEquals(StudentStatus.ACTIVE, updated.status)
|
||||||
|
assertEquals("Иванов Иван Иванович", updated.fullName)
|
||||||
|
assertEquals("контракт", updated.basis)
|
||||||
|
assertEquals(2024.toShort(), updated.enrollmentYear)
|
||||||
|
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||||
|
}
|
||||||
|
|
||||||
private fun validRows(): String =
|
private fun validRows(): String =
|
||||||
"""
|
"""
|
||||||
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
|
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class StudentImportParserTest {
|
|||||||
assertEquals("Иван", result.students[0].firstName)
|
assertEquals("Иван", result.students[0].firstName)
|
||||||
assertEquals("Иванович", result.students[0].middleName)
|
assertEquals("Иванович", result.students[0].middleName)
|
||||||
assertEquals("1001", result.students[0].studentNo)
|
assertEquals("1001", result.students[0].studentNo)
|
||||||
|
assertEquals(2, result.students[0].row)
|
||||||
|
assertEquals(3, result.students[1].row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class StudentLifecycleIntegrationTest {
|
|||||||
val student = students.findById(studentId).orElseThrow()
|
val student = students.findById(studentId).orElseThrow()
|
||||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
|
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
|
||||||
assertEquals(expectedYear, student.enrollmentYear)
|
assertEquals(expectedYear, student.enrollmentYear)
|
||||||
|
assertTrue(authService.me(studentId).readOnly)
|
||||||
|
assertTrue(authService.login(LoginRequest(credential.studentNo, credential.password)).readOnly)
|
||||||
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||||
|
|
||||||
val event =
|
val event =
|
||||||
@@ -140,6 +142,18 @@ class StudentLifecycleIntegrationTest {
|
|||||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
|
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `academic leave login is read only`() {
|
||||||
|
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
|
||||||
|
val credential = createStudent(group, "7105", "Орлов", "Олег")
|
||||||
|
val studentId = UUID.fromString(credential.id)
|
||||||
|
lifecycle.academicLeave(studentId, null, null)
|
||||||
|
|
||||||
|
val login = authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||||
|
assertTrue(login.readOnly)
|
||||||
|
assertTrue(authService.me(studentId).readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `return from academic leave moves to requested active group`() {
|
fun `return from academic leave moves to requested active group`() {
|
||||||
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
|
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ data class DefenseDto(
|
|||||||
@Service
|
@Service
|
||||||
class DefenseService(
|
class DefenseService(
|
||||||
private val sessions: DefenseSessionRepository,
|
private val sessions: DefenseSessionRepository,
|
||||||
|
private val writeGuard: StudentWriteGuard,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val DURATION: Duration = Duration.ofMinutes(30)
|
val DURATION: Duration = Duration.ofMinutes(30)
|
||||||
@@ -129,6 +130,7 @@ class DefenseService(
|
|||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
code: String,
|
code: String,
|
||||||
): DefenseDto {
|
): DefenseDto {
|
||||||
|
writeGuard.ensureWritable(studentId)
|
||||||
syncTimeouts()
|
syncTimeouts()
|
||||||
val s = requireWaitingSession(studentId, code)
|
val s = requireWaitingSession(studentId, code)
|
||||||
s.status = DefenseStatus.started.name
|
s.status = DefenseStatus.started.name
|
||||||
@@ -163,6 +165,7 @@ class DefenseService(
|
|||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
sessionId: UUID,
|
sessionId: UUID,
|
||||||
): DefenseDto {
|
): DefenseDto {
|
||||||
|
writeGuard.ensureWritable(studentId)
|
||||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class SubmissionService(
|
|||||||
private val submissions: SubmissionRepository,
|
private val submissions: SubmissionRepository,
|
||||||
private val storage: SubmissionStorage,
|
private val storage: SubmissionStorage,
|
||||||
private val academic: AcademicClient,
|
private val academic: AcademicClient,
|
||||||
|
private val writeGuard: StudentWriteGuard,
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun submit(
|
fun submit(
|
||||||
@@ -27,6 +28,7 @@ class SubmissionService(
|
|||||||
files: List<MultipartFile>,
|
files: List<MultipartFile>,
|
||||||
token: String,
|
token: String,
|
||||||
): SubmissionDto {
|
): SubmissionDto {
|
||||||
|
writeGuard.ensureWritable(studentId)
|
||||||
val payload = validatedPayload(files)
|
val payload = validatedPayload(files)
|
||||||
ensureDeadline(requireLab(labId, token))
|
ensureDeadline(requireLab(labId, token))
|
||||||
val seq = nextSeq(studentId, labId)
|
val seq = nextSeq(studentId, labId)
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ class StudentProfileService(
|
|||||||
val context = identity.getStudentContext(studentId.toString())
|
val context = identity.getStudentContext(studentId.toString())
|
||||||
requireProfileAccess(course, context, studentId, callerId, role)
|
requireProfileAccess(course, context, studentId, callerId, role)
|
||||||
|
|
||||||
val labs = courseLabs(courseId, token)
|
val labs =
|
||||||
|
courseLabs(courseId, token).filter { (_, lab) ->
|
||||||
|
role != ROLE_STUDENT || lab.status == "published"
|
||||||
|
}
|
||||||
val labIds = labs.map { it.first }
|
val labIds = labs.map { it.first }
|
||||||
val sid = studentId
|
val sid = studentId
|
||||||
val visibleGrades = gradesByLab(sid, labIds)
|
val visibleGrades = gradesByLab(sid, labIds)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import ru.ysu.lms.progress.repo.DefenseSessionRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DefenseServiceAcademicLeaveTest {
|
||||||
|
@Test
|
||||||
|
fun `academic leave student cannot start or finish defense`() {
|
||||||
|
val guard = mockk<StudentWriteGuard>()
|
||||||
|
every { guard.ensureWritable(any()) } throws ForbiddenException("только чтение")
|
||||||
|
val service = DefenseService(mockk<DefenseSessionRepository>(relaxed = true), guard)
|
||||||
|
val studentId = UUID.randomUUID()
|
||||||
|
|
||||||
|
assertThatThrownBy { service.startByCode(studentId, "123456") }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
assertThatThrownBy { service.finish(studentId, UUID.randomUUID()) }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,6 +287,7 @@ class StudentProfileIntegrationTest {
|
|||||||
id = labId.toString(),
|
id = labId.toString(),
|
||||||
number = number,
|
number = number,
|
||||||
title = "Лабораторная $number",
|
title = "Лабораторная $number",
|
||||||
|
status = "published",
|
||||||
deadlineAt = "2026-12-01T00:00:00Z",
|
deadlineAt = "2026-12-01T00:00:00Z",
|
||||||
levels =
|
levels =
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir
|
|||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.mock.web.MockMultipartFile
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
import ru.ysu.lms.progress.domain.Submission
|
import ru.ysu.lms.progress.domain.Submission
|
||||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -33,7 +34,9 @@ class SubmissionServiceTest {
|
|||||||
}
|
}
|
||||||
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
||||||
val storage = SubmissionStorage(tempDir.toString())
|
val storage = SubmissionStorage(tempDir.toString())
|
||||||
return SubmissionService(repo, storage, academic) to repo
|
val guard = mockk<StudentWriteGuard>()
|
||||||
|
every { guard.ensureWritable(any()) } returns Unit
|
||||||
|
return SubmissionService(repo, storage, academic, guard) to repo
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,6 +56,20 @@ class SubmissionServiceTest {
|
|||||||
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
|
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `academic leave student cannot submit`() {
|
||||||
|
val repo = mockk<SubmissionRepository>()
|
||||||
|
val academic = mockk<AcademicClient>()
|
||||||
|
val guard = mockk<StudentWriteGuard>()
|
||||||
|
every { guard.ensureWritable(studentId) } throws ForbiddenException("только чтение")
|
||||||
|
val service = SubmissionService(repo, SubmissionStorage(tempDir.toString()), academic, guard)
|
||||||
|
val file = MockMultipartFile("files", "main.c", "text/plain", "int main(void){}".toByteArray())
|
||||||
|
|
||||||
|
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
.hasMessageContaining("только чтение")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects xml project file renamed to source extension`() {
|
fun `rejects xml project file renamed to source extension`() {
|
||||||
val (service, _) = service()
|
val (service, _) = service()
|
||||||
|
|||||||
@@ -135,12 +135,19 @@ class AdminGroupController(
|
|||||||
@PostMapping("/groups/import")
|
@PostMapping("/groups/import")
|
||||||
fun importGroup(
|
fun importGroup(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
|
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
|
||||||
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
|
val result =
|
||||||
|
api.importGroup(
|
||||||
|
token(session),
|
||||||
|
file.originalFilename ?: "group.xlsx",
|
||||||
|
file.bytes,
|
||||||
|
restoreInactiveStatuses,
|
||||||
|
)
|
||||||
redirect.addFlashAttribute("importResult", result)
|
redirect.addFlashAttribute("importResult", result)
|
||||||
if (!result.applied) {
|
if (!result.applied) {
|
||||||
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
|
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import ru.ysu.lms.web.api.credentials
|
|||||||
import ru.ysu.lms.web.api.enrollStudent
|
import ru.ysu.lms.web.api.enrollStudent
|
||||||
import ru.ysu.lms.web.api.groupDetail
|
import ru.ysu.lms.web.api.groupDetail
|
||||||
import ru.ysu.lms.web.api.resetPassword
|
import ru.ysu.lms.web.api.resetPassword
|
||||||
import ru.ysu.lms.web.api.unenrollStudent
|
|
||||||
import tools.jackson.databind.ObjectMapper
|
import tools.jackson.databind.ObjectMapper
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
@@ -119,20 +118,4 @@ class AdminStudentController(
|
|||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups/$groupId"
|
"redirect:/admin/groups/$groupId"
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/{groupId}/students/{studentId}/unenroll")
|
|
||||||
fun unenrollStudent(
|
|
||||||
@PathVariable groupId: String,
|
|
||||||
@PathVariable studentId: String,
|
|
||||||
session: HttpSession,
|
|
||||||
redirect: RedirectAttributes,
|
|
||||||
): String =
|
|
||||||
try {
|
|
||||||
api.unenrollStudent(token(session), groupId, studentId)
|
|
||||||
redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
|
|
||||||
"redirect:/admin/groups/$groupId"
|
|
||||||
} catch (ex: ApiException) {
|
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
|
||||||
"redirect:/admin/groups/$groupId"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import ru.ysu.lms.web.api.reorderArticles
|
|||||||
import ru.ysu.lms.web.api.reorderContent
|
import ru.ysu.lms.web.api.reorderContent
|
||||||
import ru.ysu.lms.web.api.reorderSections
|
import ru.ysu.lms.web.api.reorderSections
|
||||||
import ru.ysu.lms.web.api.setContentPublished
|
import ru.ysu.lms.web.api.setContentPublished
|
||||||
|
import ru.ysu.lms.web.api.updateChapterAudience
|
||||||
import ru.ysu.lms.web.api.updateContentArticle
|
import ru.ysu.lms.web.api.updateContentArticle
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
@@ -153,16 +154,18 @@ class ContentAdminController(
|
|||||||
redirectBack(back, "redirect:/learn")
|
redirectBack(back, "redirect:/learn")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@PostMapping("/chapters/create")
|
@PostMapping("/chapters/create")
|
||||||
fun createChapter(
|
fun createChapter(
|
||||||
@RequestParam title: String,
|
@RequestParam title: String,
|
||||||
@RequestParam(required = false) slug: String?,
|
@RequestParam(required = false) slug: String?,
|
||||||
|
@RequestParam(required = false) audience: String?,
|
||||||
@RequestParam(required = false) back: String?,
|
@RequestParam(required = false) back: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.createChapter(token(session), title.trim(), slug)
|
api.createChapter(token(session), title.trim(), slug, audience)
|
||||||
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
||||||
redirectBack(back, "redirect:/learn")
|
redirectBack(back, "redirect:/learn")
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
@@ -170,6 +173,23 @@ class ContentAdminController(
|
|||||||
redirectBack(back, "redirect:/learn")
|
redirectBack(back, "redirect:/learn")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/chapters/{id}/audience")
|
||||||
|
fun updateChapterAudience(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestParam audience: String,
|
||||||
|
@RequestParam(required = false) back: String?,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
api.updateChapterAudience(token(session), id, audience)
|
||||||
|
redirect.addFlashAttribute("notice", "Аудитория главы изменена")
|
||||||
|
redirectBack(back, "redirect:/learn")
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
redirectBack(back, "redirect:/learn")
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/chapters/{id}/rename")
|
@PostMapping("/chapters/{id}/rename")
|
||||||
fun renameChapter(
|
fun renameChapter(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions")
|
||||||
|
|
||||||
package ru.ysu.lms.web.api
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
import org.springframework.core.ParameterizedTypeReference
|
import org.springframework.core.ParameterizedTypeReference
|
||||||
@@ -165,6 +167,7 @@ fun ApiClient.createChapter(
|
|||||||
token: String,
|
token: String,
|
||||||
title: String,
|
title: String,
|
||||||
slug: String?,
|
slug: String?,
|
||||||
|
audience: String? = null,
|
||||||
): Map<String, Any> {
|
): Map<String, Any> {
|
||||||
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
|
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
|
||||||
return call {
|
return call {
|
||||||
@@ -173,12 +176,29 @@ fun ApiClient.createChapter(
|
|||||||
.uri("/api/v1/content/chapters")
|
.uri("/api/v1/content/chapters")
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.body(mapOf("title" to title, "slug" to slug))
|
.body(mapOf("title" to title, "slug" to slug, "audience" to audience))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ApiClient.updateChapterAudience(
|
||||||
|
token: String,
|
||||||
|
chapterId: String,
|
||||||
|
audience: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.put()
|
||||||
|
.uri("/api/v1/content/chapters/{id}/audience", chapterId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("audience" to audience))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun ApiClient.renameChapter(
|
fun ApiClient.renameChapter(
|
||||||
token: String,
|
token: String,
|
||||||
chapterId: String,
|
chapterId: String,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ fun ApiClient.importGroup(
|
|||||||
token: String,
|
token: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
|
restoreInactiveStatuses: Boolean = false,
|
||||||
): ImportResultDto {
|
): ImportResultDto {
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
val parts = LinkedMultiValueMap<String, Any>()
|
||||||
parts.add(
|
parts.add(
|
||||||
@@ -50,8 +51,12 @@ fun ApiClient.importGroup(
|
|||||||
return call {
|
return call {
|
||||||
client
|
client
|
||||||
.post()
|
.post()
|
||||||
.uri("/api/v1/groups/import")
|
.uri { builder ->
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
builder
|
||||||
|
.path("/api/v1/groups/import")
|
||||||
|
.queryParam("restoreInactiveStatuses", restoreInactiveStatuses)
|
||||||
|
.build()
|
||||||
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.body(parts)
|
.body(parts)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
@@ -102,21 +107,6 @@ fun ApiClient.enrollStudent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ApiClient.unenrollStudent(
|
|
||||||
token: String,
|
|
||||||
groupId: String,
|
|
||||||
studentId: String,
|
|
||||||
) {
|
|
||||||
call {
|
|
||||||
client
|
|
||||||
.delete()
|
|
||||||
.uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
|
||||||
.retrieve()
|
|
||||||
.toBodilessEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ApiClient.resetPassword(
|
fun ApiClient.resetPassword(
|
||||||
token: String,
|
token: String,
|
||||||
studentId: String,
|
studentId: String,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ data class AuthResponseDto(
|
|||||||
val role: String = "",
|
val role: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val mustChangePassword: Boolean = false,
|
val mustChangePassword: Boolean = false,
|
||||||
|
val readOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -29,6 +30,7 @@ data class MeDto(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
val mustChangePassword: Boolean = false,
|
val mustChangePassword: Boolean = false,
|
||||||
val active: Boolean = true,
|
val active: Boolean = true,
|
||||||
|
val readOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LoginPayload(
|
data class LoginPayload(
|
||||||
@@ -94,6 +96,7 @@ data class ImportResultDto(
|
|||||||
val groupName: String? = null,
|
val groupName: String? = null,
|
||||||
val added: List<CredentialDto> = emptyList(),
|
val added: List<CredentialDto> = emptyList(),
|
||||||
val updated: List<String> = emptyList(),
|
val updated: List<String> = emptyList(),
|
||||||
|
val skipped: List<ImportErrorDto> = emptyList(),
|
||||||
val errors: List<ImportErrorDto> = emptyList(),
|
val errors: List<ImportErrorDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -597,6 +600,7 @@ data class ContentChapterNodeDto(
|
|||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val position: Int? = 0,
|
val position: Int? = 0,
|
||||||
val published: Boolean = true,
|
val published: Boolean = true,
|
||||||
|
val audience: String? = null,
|
||||||
val sections: List<ContentSectionNodeDto> = emptyList(),
|
val sections: List<ContentSectionNodeDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class AuthPageController(
|
|||||||
role = auth.role,
|
role = auth.role,
|
||||||
name = auth.name,
|
name = auth.name,
|
||||||
mustChangePassword = auth.mustChangePassword,
|
mustChangePassword = auth.mustChangePassword,
|
||||||
|
readOnly = auth.readOnly,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,18 @@ class NoCachePageInterceptor : HandlerInterceptor {
|
|||||||
handler: Any,
|
handler: Any,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
response.setHeader("Cache-Control", "no-store")
|
response.setHeader("Cache-Control", "no-store")
|
||||||
|
if (isPrivatePath(request.requestURI)) {
|
||||||
|
response.setHeader("X-Robots-Tag", "noindex, nofollow")
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isPrivatePath(uri: String): Boolean = uri in PRIVATE_EXACT || PRIVATE_PREFIXES.any(uri::startsWith)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val PRIVATE_EXACT = setOf("/login", "/setup", "/admin", "/teacher", "/student", "/change-password")
|
||||||
|
val PRIVATE_PREFIXES = listOf("/admin/", "/teacher/", "/student/", "/change-password", "/api/", "/learn/course/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ class AuthInterceptor(
|
|||||||
user: SessionUser,
|
user: SessionUser,
|
||||||
me: MeDto,
|
me: MeDto,
|
||||||
) {
|
) {
|
||||||
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword) return
|
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword && me.readOnly == user.readOnly) return
|
||||||
session.setAttribute(
|
session.setAttribute(
|
||||||
SessionKeys.USER,
|
SessionKeys.USER,
|
||||||
user.copy(role = me.role, mustChangePassword = me.mustChangePassword),
|
user.copy(role = me.role, mustChangePassword = me.mustChangePassword, readOnly = me.readOnly),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ class SessionTokenService(
|
|||||||
)
|
)
|
||||||
session.setAttribute(
|
session.setAttribute(
|
||||||
SessionKeys.USER,
|
SessionKeys.USER,
|
||||||
user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword),
|
user.copy(
|
||||||
|
role = auth.role,
|
||||||
|
mustChangePassword = auth.mustChangePassword,
|
||||||
|
readOnly = auth.readOnly,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
RefreshOutcome.OK
|
RefreshOutcome.OK
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ data class SessionUser(
|
|||||||
val role: String,
|
val role: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
|
val readOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = role == "ADMIN"
|
val isAdmin: Boolean get() = role == "ADMIN"
|
||||||
val isTeacher: Boolean get() = role == "TEACHER"
|
val isTeacher: Boolean get() = role == "TEACHER"
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ class StudentController(
|
|||||||
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
|
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
|
||||||
model.addAttribute("defense", defense)
|
model.addAttribute("defense", defense)
|
||||||
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
|
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
|
||||||
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending)
|
val readOnly = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.readOnly == true
|
||||||
|
model.addAttribute("readOnly", readOnly)
|
||||||
|
model.addAttribute("canSubmit", !readOnly && canSubmit(lab.deadlineAt) && !submittedOrDefending)
|
||||||
"student/lab"
|
"student/lab"
|
||||||
} catch (ignore: ApiException) {
|
} catch (ignore: ApiException) {
|
||||||
"redirect:/student/labs"
|
"redirect:/student/labs"
|
||||||
|
|||||||
9
web/src/main/resources/static/robots.txt
Normal file
9
web/src/main/resources/static/robots.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /learn/
|
||||||
|
Disallow: /learn/course/
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /teacher/
|
||||||
|
Disallow: /student/
|
||||||
|
Disallow: /change-password
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /login
|
||||||
@@ -240,15 +240,6 @@
|
|||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
|
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
|
|
||||||
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
|
||||||
method="post"
|
|
||||||
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
|
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
|
||||||
<button class="btn btn-secondary btn-sm" type="submit"
|
|
||||||
title="Удалить зачисление в группу; для отчисления с сохранением истории используйте «Отчислить»">Убрать из группы</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -487,6 +478,23 @@
|
|||||||
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
|
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
|
||||||
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
|
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
|
||||||
</p>
|
</p>
|
||||||
|
<div class="text-sm" th:if="${!#lists.isEmpty(importResult.skipped)}">
|
||||||
|
<p>
|
||||||
|
Пропущено строк: <b th:text="${#lists.size(importResult.skipped)}">0</b> —
|
||||||
|
статус и группа не изменены, требуется явное восстановление:
|
||||||
|
</p>
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Строка</th><th>Причина</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="s : ${importResult.skipped}">
|
||||||
|
<td th:text="${s.row}">—</td>
|
||||||
|
<td th:text="${s.message}">—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
|
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,6 +101,16 @@
|
|||||||
</p>
|
</p>
|
||||||
<div><label class="label" for="file">Файл со списком</label>
|
<div><label class="label" for="file">Файл со списком</label>
|
||||||
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
|
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" id="restoreInactiveStatuses" name="restoreInactiveStatuses" value="true">
|
||||||
|
<span>Восстанавливать отчисленных/выпущенных/архивных/академических</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint">
|
||||||
|
Без флага студенты в статусах «отчислен», «выпущен», «архив» или «академический отпуск»
|
||||||
|
обновляются только по ФИО и базе: их статус и группа не меняются, а строки попадут
|
||||||
|
в отчёт как пропущенные. С флагом такие студенты снова станут активными и будут
|
||||||
|
зачислены в импортируемую группу.
|
||||||
|
</p>
|
||||||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
|
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
|
||||||
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
|
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||||||
|
<form th:if="${chapter.audience != null}" class="inline-form"
|
||||||
|
th:action="@{/admin/content/chapters/{id}/audience(id=${chapter.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="back" value="/learn">
|
||||||
|
<select class="input filter-select" name="audience" onchange="this.form.submit()"
|
||||||
|
title="Аудитория главы — изменится сразу">
|
||||||
|
<option value="ALL" th:selected="${chapter.audience == 'ALL'}">Все</option>
|
||||||
|
<option value="STUDENTS" th:selected="${chapter.audience == 'STUDENTS'}">Студенты</option>
|
||||||
|
<option value="TEACHERS" th:selected="${chapter.audience == 'TEACHERS'}">Преподаватели</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
|
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="id" th:value="${chapter.id}">
|
<input type="hidden" name="id" th:value="${chapter.id}">
|
||||||
@@ -206,6 +217,17 @@
|
|||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<div><label class="label" for="chTitle">Название</label>
|
<div><label class="label" for="chTitle">Название</label>
|
||||||
<input class="input" id="chTitle" name="title" required></div>
|
<input class="input" id="chTitle" name="title" required></div>
|
||||||
|
<div><label class="label" for="chAudience">Аудитория</label>
|
||||||
|
<select class="input" id="chAudience" name="audience">
|
||||||
|
<option value="ALL" selected>Все — доступно без входа</option>
|
||||||
|
<option value="STUDENTS">Студенты — после входа с ролью студента</option>
|
||||||
|
<option value="TEACHERS">Преподаватели — после входа с ролью преподавателя</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">
|
||||||
|
Главы на этой странице глобальные. <b>Все</b> видит любой посетитель,
|
||||||
|
<b>Студенты</b> и <b>Преподаватели</b> — только авторизованные пользователи
|
||||||
|
с соответствующей ролью. Изменить аудиторию можно потом в карточке главы.
|
||||||
|
</p></div>
|
||||||
<button class="btn btn-primary" type="submit">Создать</button>
|
<button class="btn btn-primary" type="submit">Создать</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
<span th:text="${comment.text}">—</span>
|
<span th:text="${comment.text}">—</span>
|
||||||
<span class="text-sm text-muted" th:if="${comment.author != null}"
|
<span class="text-sm text-muted" th:if="${comment.author != null}"
|
||||||
th:text="|— ${comment.author.login}|"></span>
|
th:text="|— ${comment.author.login}|"></span>
|
||||||
|
<span class="text-sm text-muted" th:if="${comment.author == null}">— автор не указан</span>
|
||||||
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
|
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
|
||||||
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
|
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
<h2 class="section-title">Сдача работы</h2>
|
<h2 class="section-title">Сдача работы</h2>
|
||||||
<div class="card form-card form-stack mb-4">
|
<div class="card form-card form-stack mb-4">
|
||||||
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p>
|
<p class="text-sm text-muted" th:if="${readOnly}">Академический отпуск: доступ только для чтения. Сдавать решения и проходить защиты нельзя.</p>
|
||||||
|
<p class="text-sm text-muted" th:if="${!readOnly and !canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p>
|
||||||
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
|
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
<div th:if="${defense == null}" class="text-muted">
|
<div th:if="${defense == null}" class="text-muted">
|
||||||
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
|
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
|
||||||
</div>
|
</div>
|
||||||
<th:block th:if="${defense != null and defense.status == 'waiting'}">
|
<th:block th:if="${defense != null and defense.status == 'waiting' and !readOnly}">
|
||||||
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
|
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
|
||||||
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
|
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
<p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
|
<p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
|
||||||
<h3 class="group-title">Ваше индивидуальное задание</h3>
|
<h3 class="group-title">Ваше индивидуальное задание</h3>
|
||||||
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
|
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
|
||||||
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
|
<form class="mt-4" th:if="${!readOnly}" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
|
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
|||||||
import org.thymeleaf.web.servlet.JakartaServletWebApplication
|
import org.thymeleaf.web.servlet.JakartaServletWebApplication
|
||||||
import ru.ysu.lms.web.api.AdminUserDto
|
import ru.ysu.lms.web.api.AdminUserDto
|
||||||
import ru.ysu.lms.web.api.GroupDetailDto
|
import ru.ysu.lms.web.api.GroupDetailDto
|
||||||
|
import ru.ysu.lms.web.api.ImportErrorDto
|
||||||
|
import ru.ysu.lms.web.api.ImportResultDto
|
||||||
import ru.ysu.lms.web.api.LabSummaryDto
|
import ru.ysu.lms.web.api.LabSummaryDto
|
||||||
import ru.ysu.lms.web.api.PromotionItemDto
|
import ru.ysu.lms.web.api.PromotionItemDto
|
||||||
import ru.ysu.lms.web.api.PromotionRunDetailDto
|
import ru.ysu.lms.web.api.PromotionRunDetailDto
|
||||||
@@ -204,13 +206,65 @@ class AdminStage4TemplateTest {
|
|||||||
assertThat(html).contains("/admin/groups/g1/archive")
|
assertThat(html).contains("/admin/groups/g1/archive")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s1/academic-leave")
|
assertThat(html).contains("/admin/groups/g1/students/s1/academic-leave")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s1/expel")
|
assertThat(html).contains("/admin/groups/g1/students/s1/expel")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s1/unenroll")
|
assertThat(html).doesNotContain("/admin/groups/g1/students/s1/unenroll")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s2/return")
|
assertThat(html).contains("/admin/groups/g1/students/s2/return")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s3/restore")
|
assertThat(html).contains("/admin/groups/g1/students/s3/restore")
|
||||||
assertThat(html).contains("/admin/groups/g1/students/s4/restore")
|
assertThat(html).contains("/admin/groups/g1/students/s4/restore")
|
||||||
assertThat(html).contains("Вернуть в группу")
|
assertThat(html).contains("Вернуть в группу")
|
||||||
assertThat(html).contains("Восстановить в группу")
|
assertThat(html).contains("Восстановить в группу")
|
||||||
assertThat(html).contains("Убрать из группы")
|
assertThat(html).doesNotContain("Убрать из группы")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `group import form requires explicit restoration flag`() {
|
||||||
|
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))
|
||||||
|
|
||||||
|
assertThat(html).contains("name=\"restoreInactiveStatuses\"")
|
||||||
|
assertThat(html).contains("value=\"true\"")
|
||||||
|
assertThat(html).contains("Восстанавливать отчисленных/выпущенных/архивных/академических")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `group import result shows skipped inactive rows`() {
|
||||||
|
val group =
|
||||||
|
GroupDetailDto(
|
||||||
|
id = "g1",
|
||||||
|
name = "ПИ-21БО",
|
||||||
|
status = "ACTIVE",
|
||||||
|
students = emptyList(),
|
||||||
|
)
|
||||||
|
val model =
|
||||||
|
linkedMapOf<String, Any?>(
|
||||||
|
"group" to group,
|
||||||
|
"labs" to emptyList<LabSummaryDto>(),
|
||||||
|
"candidates" to emptyList<Any>(),
|
||||||
|
"studentGrades" to emptyList<GroupStudentRow>(),
|
||||||
|
"labPublication" to emptyMap<String, GroupLabPublication>(),
|
||||||
|
"labSubmissionCounts" to emptyMap<String, Int>(),
|
||||||
|
"attestations" to emptyList<GroupAttestation>(),
|
||||||
|
"publishedLabsCount" to 0,
|
||||||
|
"profileCourseId" to null,
|
||||||
|
"teacherView" to false,
|
||||||
|
"importResult" to
|
||||||
|
ImportResultDto(
|
||||||
|
applied = true,
|
||||||
|
groupId = "g1",
|
||||||
|
groupName = "ПИ-21БО",
|
||||||
|
skipped =
|
||||||
|
listOf(
|
||||||
|
ImportErrorDto(
|
||||||
|
row = 7,
|
||||||
|
message = "Студент 2001 в статусе EXPELLED: статус и группа не изменены",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val html = render("admin/group", model)
|
||||||
|
|
||||||
|
assertThat(html).contains("Пропущено строк")
|
||||||
|
assertThat(html).contains("Студент 2001 в статусе EXPELLED")
|
||||||
|
assertThat(html).contains("7")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class TeacherGradebookTemplateTest {
|
|||||||
assertThat(html).doesNotContain("/students/s1/archive")
|
assertThat(html).doesNotContain("/students/s1/archive")
|
||||||
assertThat(html).doesNotContain("/students/s1/expel")
|
assertThat(html).doesNotContain("/students/s1/expel")
|
||||||
assertThat(html).doesNotContain("/students/s1/unenroll")
|
assertThat(html).doesNotContain("/students/s1/unenroll")
|
||||||
|
assertThat(html).doesNotContain("Убрать из группы")
|
||||||
assertThat(html).doesNotContain("/attestations")
|
assertThat(html).doesNotContain("/attestations")
|
||||||
assertThat(html).doesNotContain("modal-add-attestation")
|
assertThat(html).doesNotContain("modal-add-attestation")
|
||||||
assertThat(html).doesNotContain("modal-add-student")
|
assertThat(html).doesNotContain("modal-add-student")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user