Compare commits

..

10 Commits

Author SHA1 Message Date
a42bc64193 fix: края архива, истории, пагинации и лимита попыток 2026-10-29 21:05:44 +03:00
82394cc688 feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости
- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
2026-10-29 20:51:27 +03:00
7ef5c866a3 fix(web): устойчивые ошибки courseNo и login E2E 2026-10-29 20:01:40 +03:00
65ed08f5c2 feat(web): перевести браузерные URL курсов на courseNo
- teacher/admin course pages, content, gradebook, profile, downloads
- courseNo -> UUID только на границе REST API
- обновлены e2e, шаблоны, тесты и docs
- LDD-UI-002; baseline 59 claims / 385 тестов
2026-10-29 19:51:03 +03:00
b57a5ffbce docs: уточнить остаточные P1/P2 после импорта 2026-10-29 19:24:09 +03:00
1b98b1828d fix(import): обновлять updated_at при восстановлении аккаунта 2026-10-29 19:23:35 +03:00
1570196cc7 fix(import): построчные ошибки до записи
- preflight занятого логина и неактивной целевой группы
- applied=false с номером строки, без частичных студентов
- LDD-IMPORT-003; baseline 58 claims / 368 тестов
2026-10-29 19:20:16 +03:00
238c689175 docs: актуализировать счётчики тестов по модулям 2026-10-29 18:59:19 +03:00
9412d87fed 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
2026-10-29 18:58:21 +03:00
9cb4d0d263 docs: обновить baseline после hotfix действий статей 2026-10-29 12:04:43 +03:00
114 changed files with 3619 additions and 601 deletions

View File

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

View File

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **51 claim, 346 тестов, 18/18 browser E2E**.
Baseline: **66 claims, 398 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера)
@@ -152,16 +152,16 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
с одноразовым паролем; преподаватель видит только назначенные курсы
(`/teacher`), их группы, студентов и профили. Курсы и лабораторные
фильтруются по роли, внутренние вызовы identity защищены `LMS_INTERNAL_TOKEN`.
- **Профиль студента курса**: `/teacher/courses/{id}/students/{id}` и админский
- **Профиль студента курса**: `/teacher/courses/{courseNo}/students/{studentId}` и админский
маршрут показывают сводку, оценки, комментарии с авторами, защиты и все
попытки сдачи со скачиванием ZIP; студенту API отдаёт только его профиль
и опубликованные данные.
- **Работа преподавателя**: ведомость группы переиспользует
`/admin/groups/{groupId}?courseId=...` без админских контролов — оценки и
`/admin/groups/{groupId}?courseNo=...` без админских контролов — оценки и
комментарии, публикация, уровни/варианты и раздача, защиты (код/reset),
скачивание попыток и ZIP группы; лабораторные/уровни/варианты своего курса,
привязка/отвязка групп на `/teacher/courses/{id}`, авторские материалы на
`/teacher/courses/{id}/content`. Чужой курс отклоняется на сервисах.
привязка/отвязка групп на `/teacher/courses/{courseNo}`, авторские материалы на
`/teacher/courses/{courseNo}/content`. Чужой курс отклоняется на сервисах.
- **Аудит**: чувствительные действия (профили, скачивания, оценки, защиты,
курсы, группы, материалы, преподаватели, жизненный цикл) пишутся в
`identity.audit_events`; админ смотрит `/admin/audit` с фильтром по действию,

View File

@@ -65,7 +65,7 @@ LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и о
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *
LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
```
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,
@@ -130,10 +130,24 @@ git pull
`deploy.sh up` **обязательно** делает backup перед обновлением. Если backup
завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются
автоматически при старте сервисов: identity `V4–V13` (`V10` создаёт
автоматически при старте сервисов: identity `V4–V14` (`V10` создаёт
`identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` —
`absolute_expires_at`, `V13` — keyset-индексы аудита), academic `V6–V12`,
progress `V13`.
`absolute_expires_at`, `V13` — keyset-индексы аудита, `V14` — финальные
NOT NULL/unique ограничения когорты), academic `V6–V12`, progress `V13`.
Перед первым деплоем с legacy-данными проверьте, что у всех групп разобрана
когорта (V5 заполняет её из имени `ПОТОК-КУРСНОМЕРСТЕПЕНЬ`):
```bash
docker compose exec -T postgres psql -U lms -d lms -c "
SELECT id, name, stream, degree, course, number, enrollment_year
FROM identity.groups
WHERE stream IS NULL OR degree IS NULL OR course IS NULL
OR number IS NULL OR enrollment_year IS NULL;"
```
Если строки есть, заполните поля вручную; иначе `V14` намеренно остановит
миграцию с понятной ошибкой. Возврат из архива доступен на странице группы.
После обновления проверьте, что админ открывает `/admin/audit` и видит события
(например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и

View File

@@ -103,7 +103,7 @@ assets/ # картинки для md
Материалы видны всем; глобальные материалы редактирует только администратор
(«Материалы»). Преподаватель редактирует материалы своего курса на
`/teacher/courses/{id}/content`.
`/teacher/courses/{courseNo}/content`.
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и

View File

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

View File

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

View File

@@ -211,11 +211,11 @@
Номера уточняются при реализации, чтобы не конфликтовать с максимумами master
(identity V3, **academic V5**, progress V12). Максимумы этой ветки:
identity V13, **academic V12**, progress V13.
identity V14, **academic V12**, progress V13.
| Сервис | Миграции | Содержание |
|---|---|---|
| identity | V4–V13 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен); `V12` — `absolute_expires_at`; `V13` — keyset-индексы аудита |
| identity | V4–V14 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен); `V12` — `absolute_expires_at`; `V13` — keyset-индексы аудита; `V14` — NOT NULL/уникальность когорты |
| academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway |
| progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` |
@@ -336,24 +336,29 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**346**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims
(25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт:
тестов/линтеров (**398**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V14`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **66 claims
(27 P0 / 39 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6
- **Импорт.** Старый формат не поддерживается, принимается только шаблон v2.
Номер группы — числовой; отдельного требования к ведущим нулям нет.
CSV/XLSX preflight покрыт тестами; гонка с параллельной вставкой логина
всё ещё может дать общий 409.
- **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)`
с offset-fallback для старых ссылок; `GET /api/v1/labs/details` отдаёт все
лабораторные курса с уровнями и вариантами одним запросом; группы курса —
одним вызовом. Ведомость по-прежнему запрашивает сдачи по выбранной
лабораторной — это не N+1 по студентам.
с offset-fallback; `GET /api/v1/labs/details` отдаёт все лабораторные курса
одним запросом; группы курса — одним вызовом. Сдачи ведомости собираются
одним запросом на все выбранные лабы; группы и плоский список материалов
поддерживают `limit`/`offset`. Страница группы и профиль имеют собственные
лимиты; дерево материалов по-прежнему загружается целиком для навигации.
- **Legacy-активы.** Файлы без статьи уже не публичны; админская страница
`/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся
прогнать инвентаризацию на production/staging и привязать или удалить файлы.
- **Визуальная регрессия.** Playwright покрывает сценарии и mobile, но
screenshot-diff отсутствует.
- **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная
задача (`LMS_REFRESH_TOKEN_RETENTION`).
@@ -378,7 +383,7 @@ identity V13, **academic V12**, progress V13.
- Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней /
7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и
закрытие доступа отзывают refresh.
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V13`).
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V14`).
- CI (Gitea + Woodpecker) на каждом push/MR прогоняет
`test ktlintCheck detekt evidenceCheck` и smoke полного стека; полный
`release` обязателен для тегов.

View File

@@ -151,7 +151,9 @@ Authorization: Bearer <JWT>
`grade.generalComment`; отдельные комментарии — массив `comments`.
- `author` может быть `null`, если автор не сохранён (старые данные).
- `averageScore` — среднее по выставленным оценкам, `null` если оценок нет.
- `submissions` отсортированы по `seq`; файлы — имена без пути.
- `submissions` — последние попытки по убыванию `seq`, по умолчанию не более
50 (`submissionLimit`, максимум 500; `0` — все); `submissionsTotal` — всего попыток,
`attemptsCount` — тоже полное число. Файлы — имена без пути.
- `downloadUrl` относительный; web-BFF добавляет авторизацию.
## 4. Скачивание попытки
@@ -244,7 +246,9 @@ GET /api/v1/internal/courses/{courseId}/labs
- защиты — `defense_sessions` по `student_id` и `lab_id IN (...)`;
- публикации — `grade_publications`/`grade_publication_students` по группе и лабам.
- Список лаб курса кэшируется на время запроса.
- Для больших курсов — пагинация попыток (по умолчанию все, но с лимитом).
- Для больших курсов — лимит попыток: `submissionLimit` (по умолчанию 50,
максимум 500, `0` — все) и `submissionsTotal` для индикатора «показаны последние N»;
`attemptsCount` не зависит от лимита.
- Индексы: `submissions(student_id, lab_id, seq)` уже уникален;
`grade_comments(student_id, lab_id)` уже есть; `lab_grades(lab_id)` добавлен;
`defense_sessions(student_id, lab_id)` уникален.
@@ -273,7 +277,9 @@ GET /api/v1/internal/courses/{courseId}/labs
- Аноним — `401`.
- Скачивание своей попытки — `200`; чужой — `403`.
- Студент не видит неопубликованные оценки/комментарии.
- Все попытки возвращаются, а не только последняя.
- Все попытки возвращаются (в пределах `submissionLimit`), а не только последняя;
`submissionsTotal`/`attemptsCount` показывают полное число.
- Профиль на 10 лабораторных делает не более 15 SQL-запросов (без N+1).
- Комментарии содержат автора или `null`.
- Запрос не делает N+1 (проверка по числу SQL-запросов в интеграционном тесте).

View File

@@ -1,5 +1,7 @@
# 12. Отчёт этапа 2: academic (курсы и лабораторные)
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
**Дата:** 2026-10-27.
**Статус:** этап 2 выполнен; этап 3 (доступ и профиль студента курса) — следующий.

View File

@@ -1,5 +1,7 @@
# 13. Отчёт этапа 3: доступ и профиль студента курса
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
**Дата:** 2026-10-27.
**Статус:** этап 3 выполнен; этап 4 (повышение и жизненный цикл) — следующий.

View File

@@ -64,7 +64,7 @@
## Авторство материалов преподавателем (добавлено на этапе 6)
Преподаватель своего курса редактирует главы, разделы и статьи на
`/teacher/courses/{id}/content`: создание/переименование/удаление, markdown-
`/teacher/courses/{courseNo}/content`: создание/переименование/удаление, markdown-
редактор с предпросмотром, публикация/снятие с публикации. API —
`/api/v1/courses/{courseId}/content/**`; любая изменяемая сущность проверяется
на принадлежность курсу. Глобальные материалы (`course_id IS NULL`,

View File

@@ -65,15 +65,15 @@
| Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:|
| identity-service | 98 | 0 | 0 | 0 |
| academic-service | 99 | 0 | 0 | 0 |
| progress-service | 36 | 0 | 0 | 0 |
| web | 106 | 0 | 0 | 0 |
| identity-service | 105 | 0 | 0 | 0 |
| academic-service | 106 | 0 | 0 | 0 |
| progress-service | 38 | 0 | 0 | 0 |
| web | 129 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **346** | **0** | **0** | **0** |
| **Итого на момент этапа 6** | **385** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **346 тестов, 0 failures**
На момент этапа 6 LDD-baseline — **385 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 346 тестов, 0 failures)
Проверено автоматическими тестами (прогон этапа 6 — 385 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
@@ -252,10 +252,10 @@ legacy-активов на production, сдачи по выбранной лаб
`/admin/labs`, `/admin/teachers`, `/admin/promotion`.
- [ ] **Преподаватель:** вход, `/teacher`, карточка своего курса (имена групп,
привязка/отвязка группы по `available-groups`), ведомость
`/admin/groups/{groupId}?courseId=...` без админских контролов, оценка и
`/admin/groups/{groupId}?courseNo=...` без админских контролов, оценка и
публикация, уровень/вариант и раздача, защита (код/reset), профиль студента,
скачивание попытки и ZIP группы, авторство материалов
`/teacher/courses/{id}/content`.
`/teacher/courses/{courseNo}/content`.
- [ ] **Студент:** вход, дашборд, лабораторная, сдача/история, курсовые
материалы; видны только опубликованные оценки и комментарии.
- [ ] **Материалы курса:** админ и преподаватель видят скрытые статьи, студент —
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**51 claims, 25 P0 / 26 P1**),
LDD-контур на момент этапа 6: реестр `docs/evidence/claims.json` (**59 claims, 27 P0 / 32 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат:
**51/51 claims proven, P0 gaps = 0, 346 тестов, 0 failures**; mutation strength
**59/59 claims proven, P0 gaps = 0, 385 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.

View File

@@ -1,6 +1,6 @@
# 17. Handoff: состояние проекта после закрытия продуктовых рисков
**Дата:** 2026-10-28.
**Дата:** 2026-10-29.
**Ветка:** `feature/groups-courses`.
**История:** `git log --oneline`; последние изменения — Woodpecker CI и
изоляция тестовой среды на отдельной VM.
@@ -20,8 +20,10 @@
- Роли `ADMIN`, `TEACHER`, `STUDENT`.
- Группы-когорты: `stream`, `course`, `number`, `degree`, `enrollment_year`,
`status`; история членства `membership_history`.
- Импорт групп Excel v2, перевод/академ/отчисление/выпуск/архив/восстановление.
`status`; история членства `membership_history`. `V14` делает поля когорты
`NOT NULL` и уникальность полной; архивная группа возвращается из архива.
- Импорт групп Excel v2 (CSV/XLSX с row-level preflight), перевод/академ/
отчисление/выпуск/архив/восстановление.
- Удаление студента с усиленным подтверждением (`login` или ФИО).
- Вход закрыт для отчисленных, выпущенных, архивных студентов и архивных
аккаунтов; архивные/выпущенные группы также блокируют вход.
@@ -62,6 +64,9 @@
`/admin/legacy-assets` и `scripts/legacy-assets.sh` для работы со стендом.
- Старые прямые ссылки на бесхозные файлы у обычных пользователей ломаются
осознанно.
- Студенческие страницы материалов не показывают админские действия статей
(удаление/правка/публикация/перемещение); регрессия закрыта тестом
`LearnSectionTemplateTest` в master-хотфиксе `920d96a`.
**JWT и сессия**
- Access JWT: **15 минут** (`LMS_ACCESS_TOKEN_TTL`).
@@ -113,8 +118,8 @@
### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`.
- **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**.
- **346 автотестов**, 0 failures/errors/skipped.
- **66 claims**: 27 P0 + 39 P1; **66/66 proven, P0 gaps = 0**.
- **398 автотестов**, 0 failures/errors/skipped.
- Mutation testing:
- `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%.
@@ -140,7 +145,7 @@
| Сервис | Диапазон | Что важно |
|---|---|---|
| identity | `V4–V13` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`), absolute (`V12`), keyset-индексы (`V13`) |
| identity | `V4–V14` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`), absolute (`V12`), keyset-индексы (`V13`), финальные ограничения когорты (`V14`) |
| academic | `V6–V12` | курсы, `labs.course_id`, главы/аудитории, индексы, `article_assets`, backfill |
| progress | `V13` | автор комментария/оценки; V13 идемпотентна |
@@ -150,6 +155,8 @@
`deploy/preflight/progress-v10-v13.sql`, объекты не удалять.
- Production/master: progress V11/V12 уже есть, затем V13.
- `labs.course_id NOT NULL` — post-bootstrap, не Flyway.
- `V14` намеренно падает на legacy-группах с NULL-когортой; SQL для проверки —
в `deploy/DEPLOY.md`.
### 1.10. Деплой и backup
@@ -191,8 +198,8 @@ cat build/reports/evidence/index.md
```
Последний подтверждённый результат:
- `346` тестов, 0 failures;
- `51/51` claims;
- `398` тестов, 0 failures;
- `66/66` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS;
- smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -222,10 +229,12 @@ cat build/reports/evidence/index.md
Сейчас 6 сценариев × 3 браузера, без screenshot-diff. Добавлять по мере
роста UI.
4. **Пакетные сдачи в ведомости.**
Детали лабораторных курса уже пакетные, но ведомость запрашивает сдачи по
выбранной лабораторной отдельно. Оптимизировать, если появятся медленные
курсы.
4. **Гонка импорта.**
Preflight закрывает известные конфликты, но параллельная вставка логина
всё ещё даёт общий 409.
5. **Restore на staging.**
Backup/restore-скрипты готовы; нужен реальный прогон на стенде с логом.
«Контингент»/`next.uniyar.ac.ru` **исключён из плана** решением от 2026-10-28:
нет API-спецификации и доступа к внешней системе. При появлении нового ТЗ
@@ -271,9 +280,9 @@ cat build/reports/evidence/index.md
1. `git status --short --branch` — ожидается чистое дерево на
`feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 346 тестов
и 51/51 claims.
`docs/evidence/baseline-2026-10-29.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 398 тестов
и 66 claims, 66/66 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
@@ -285,16 +294,20 @@ cat build/reports/evidence/index.md
- `docs/08-groups-courses-plan.md` — план и статус.
- `docs/16-stage6-report.md` — итоговый отчёт, deploy и ограничения.
- `docs/evidence/claims.json` — LDD-реестр.
- `docs/evidence/baseline-2026-10-27.md` — фактический baseline.
- `docs/evidence/baseline-2026-10-29.md` — фактический baseline.
- `services/identity-service/.../auth/refresh/` — refresh-токены, absolute,
cleanup.
- `services/identity-service/.../db/migration/V11__refresh_tokens.sql`,
`V12__refresh_absolute.sql`, `V13__audit_keyset_indexes.sql`.
`V12__refresh_absolute.sql`, `V13__audit_keyset_indexes.sql`,
`V14__cohort_constraints.sql`.
- `services/identity-service/.../audit/` — аудит и keyset-курсор.
- `services/academic-service/.../content/` — материалы/активы.
- `services/academic-service/.../course/CourseGroupService.kt` — bulk-группы.
- `services/academic-service/.../lab/LabService.kt` — `details(courseId)`.
- `services/progress-service/.../AcademicClient.kt` — пакетный вызов лаб.
- `services/progress-service/.../SubmissionService.kt` — пакетные сдачи
ведомости; `profile/StudentProfileService.kt` — контракт профиля и лимит
попыток.
- `services/progress-service/.../CourseScopeService.kt` — course-scope.
- `web/.../session/SessionTokenService.kt`, `SessionLifetime.kt`,
`SessionCookieRefreshFilter.kt`.

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

@@ -0,0 +1,185 @@
# 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. Сводка
| Область | Статус | Что осталось |
|---|---|---|
| Роли, группы-когорты, статусы, история | implemented | Финальные ограничения когорты, возврат из архива и блок истории членства на странице группы |
| Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service |
| Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID |
| «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив |
| Импорт v2 | partial | Флаг восстановления и row-level отчёт для CSV/XLSX есть; гонка с параллельной вставкой логина всё ещё даёт общий 409 |
| Курсы, преподаватели, группы курса | implemented | — |
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
| Профиль студента | implemented | Контракт 09 (`variant/level/updatedBy/name`), лимит попыток и SQL-тест «без N+1» сделаны |
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
| Аудит | implemented | `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL/GROUP_ARCHIVE/GROUP_RESTORE` проверены интеграционным тестом |
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы/группы/сдачи, пагинация групп и плоского списка материалов, лимит попыток профиля; остаются visual regression и SLA-нагрузка |
| LDD | implemented | 66 claims (27 P0 / 39 P1); `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. **Профиль.**
- Старые комментарии без автора показывают «автор не указан».
- Студенческий профиль не показывает черновики лабораторных.
10. **Браузерные URL курсов — `courseNo`.**
Страницы преподавателя, админа, материалы и профиль студента принимают
человеческий номер курса; REST API продолжает работать с UUID.
11. **Импорт: ошибки до записи.**
- Занятый логин и неактивная целевая группа возвращают `applied=false`
с номером строки и не создают частичных записей.
- Тесты: `GroupServiceIntegrationTest#import reports occupied login...`,
`#import reports inactive target group...`, XLSX-варианты
`#xlsx import reports occupied login before writing()`,
`#xlsx import reports inactive target group before writing()`.
12. **Профиль приведён к контракту 09.**
- `variant.id`, `level.id/code`, `grade.updatedBy`, `author.name`.
- ФИО автора берётся из identity (fallback — логин).
- `submissionLimit` (по умолчанию 50) и `submissionsTotal`; `attemptsCount`
остаётся полным.
- SQL-тест: 10 лабораторных — не более 15 запросов.
- Тесты: `StudentProfileIntegrationTest`, `ProfileDtoJsonTest`,
`StudentProfileTemplateTest`, `InternalControllerIntegrationTest`.
13. **Финальные ограничения когорты.**
- `V14__cohort_constraints.sql`: `NOT NULL` для 5 полей, полный unique index,
строгие check-констрейнты; legacy-строки блокируют миграцию с явной ошибкой.
- `scripts/ldd-migration-upgrade.sh` проверяет `cohort_not_null=5` и
`cohort_unique_full=1` на пути master→branch.
14. **Возврат группы из архива.**
- `POST /api/v1/groups/{id}/restore`, кнопка на странице группы, аудит
`GROUP_RESTORE`; повторный возврат активной группы — 400.
15. **Пагинация и пакетные сдачи.**
- `GET /api/v1/groups?page=&limit=` и страница `/admin/groups`.
- `GET /api/v1/admin/content/articles?offset=&limit=`.
- `SubmissionService.latestPerStudentByLab` — один запрос на все лабы
ведомости вместо запроса на каждую.
16. **Аудит ростера.**
- Интеграционный тест проверяет шесть событий и актора.
17. **История членства на странице группы.**
- `GET /api/v1/groups/{id}/history` отдаёт студента, действие,
откуда/куда и причину; шаблон показывает последние 200 событий в МСК.
- Тесты: `GroupServiceIntegrationTest`, `AdminStage4TemplateTest`.
## 3. Остаточные отличия от цели
### P1
- **Backup restore на staging** не подтверждён артефактом/логом.
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
### Осознанные границы
- **Автоматический догон повышения за предыдущий календарный год не делаем.**
Планировщик не может безопасно восстановить историческое состояние групп:
текущие `course` уже могут быть изменены. Догон в том же году после 31 августа
работает; для прошлого года админ запускает dry-run с явным годом и проверяет
план вручную. Это защищает от повторного повышения по устаревшему состоянию.
### P2
- **Импорт: гонка.** Preflight закрывает известные конфликты, но гонка с
параллельной вставкой логина всё ещё даёт общий 409 вместо строки отчёта.
- **Визуальная регрессия.** Playwright проверяет сценарии и мобильный viewport,
но screenshot-diff нет.
- 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`, **66 claims** (27 P0 / 39 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **66/66 claims**, 398 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с.
## 6. Следующий порядок работ
1. Остались операционные P1: restore на staging и первый реальный
Woodpecker pipeline.
2. P2: гонка импорта, визуальная регрессия, повышение mutation-покрытия
и SLA-нагрузка.
3. Перед production: ручной разбор legacy-когорт по SQL из `deploy/DEPLOY.md`,
затем backup и деплой.
4. После каждого изменения прогонять `./scripts/ldd-release.sh` и обновлять
`docs/evidence/baseline-2026-10-29.md`.

View File

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

View File

@@ -87,10 +87,10 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков:
- **346 автоматических тестов**, 0 failures/errors;
- **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы,
- **398 автоматических тестов**, 0 failures/errors;
- **66 claims**: 27 P0 + 39 P1, **66/66 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
(включая secure-by-default legacy-файлы), миграции V11–V14, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
копирование лаб, keyset-пагинация, пакетные детали лабораторных,
абсолютный потолок сессии, runtime, mutation и browser E2E;
@@ -114,4 +114,4 @@ Kotlin-модуль, поэтому DTO с default-значениями треб
реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок
access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и
«ведущие нули» больше не являются открытыми рисками.
Результаты — в `baseline-2026-10-27.md`.
Результаты — в `baseline-2026-10-29.md`; предыдущий срез — `baseline-2026-10-27.md`.

View File

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

View File

@@ -0,0 +1,44 @@
# LDD baseline: 2026-10-29
Актуальный прогон после закрытия P1/P2-остатка: контракт профиля, финальные
ограничения когорты, возврат группы из архива, пагинация, пакетные сдачи,
row-level XLSX-импорт и audit-события ростера.
## Результат
| Проверка | Результат |
|---|---|
| JUnit/интеграционные тесты | **398**, 0 failures/errors/skipped |
| ktlint + detekt | зелёные |
| Claims | **66** (27 P0 / 39 P1), **66/66 proven**, P0 gaps = 0 |
| Migration master→branch | PASS; identity `V4–V14`, повторные V13/V14 |
| Mutation `CourseAccessService` | 75% test strength (порог 70%) |
| Mutation `PromotionPlanner` | 69% test strength (порог 60%) |
| Browser E2E | 18/18 (Chromium/Firefox/WebKit) |
| Load smoke | 125 запросов, 0 ошибок, p95 0.918 с (порог < 1 с) |
## Что добавлено в этой итерации
- профиль соответствует `docs/09`: `variant.id`, `level.id/code`,
`grade.updatedBy`, `author.name`; `submissionLimit`/`submissionsTotal` и
SQL-тест «без N+1» на 10 лабораторных;
- `V14__cohort_constraints.sql`: `NOT NULL` для пяти полей когорты, полный
unique index и строгие check-ограничения; legacy-строки останавливают
миграцию с явной ошибкой;
- возврат группы из архива (`POST /api/v1/groups/{id}/restore`) с аудитом;
- пагинация `/admin/groups` и плоского списка материалов;
- `latestPerStudentByLab` — один запрос сдач на все лабораторные ведомости;
- XLSX-тесты preflight-ошибок импорта (занятый логин, неактивная группа);
- аудит `GROUP_CREATE/STUDENT_CREATE/GROUP_ENROLL/GROUP_IMPORT/GROUP_ARCHIVE/GROUP_RESTORE`;
- история членства на странице группы (последние 200 событий).
## Остаточные ограничения
- гонка импорта с параллельной вставкой логина всё ещё даёт общий 409;
- screenshot-diff/визуальной регрессии нет;
- mutation testing не 100%, нагрузочный smoke не является SLA;
- restore на staging, первый реальный Woodpecker pipeline, инвентаризация
legacy-активов и daily backup timer требуют доступа к стенду/серверу.
Артефакты прогона: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`,
отчёт gate — `build/reports/evidence/index.md`.

View File

@@ -43,11 +43,11 @@
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#public tree contains only global chapters with ALL audience()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous tree contains only global chapters with ALL audience()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
],
"limitations": "Legacy-активы без article_assets остаются публичными и вынесены в отдельный риск."
"limitations": "Legacy-активы без article_assets закрыты отдельными claims LDD-ASSET-002/003."
},
{
"id": "LDD-ACCESS-004",
@@ -435,12 +435,12 @@
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Схема master + синтетические данные → миграции identity V4–V13, academic V6–V12, progress V13; V10 audit, V11 refresh, V12 absolute-expiry и V13 keyset-индексы создаются, V13 идемпотентна.",
"oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов",
"claim": "Схема master + синтетические данные → миграции identity V4–V14, academic V6–V12, progress V13; V10 audit, V11 refresh, V12 absolute-expiry, V13 keyset-индексы, V14 NOT NULL/unique когорты; V13/V14 идемпотентны.",
"oracle": "postflight checks = required; повторные V13/V14 не падают",
"runtime": [
"migration.master_upgrade"
],
"limitations": "Запускается отдельным скриптом на одноразовой БД; не входит в unit-CI."
"limitations": "V14 намеренно блокирует legacy-группы с неразобранной когортой."
},
{
"id": "LDD-RUNTIME-001",
@@ -659,16 +659,18 @@
},
{
"id": "LDD-AUTH-005",
"title": "Refresh/logout работают через реальный gateway",
"title": "Gateway пропускает refresh/logout без access-JWT",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
"oracle": "runtime.auth_refresh",
"runtime": [
"runtime.auth_refresh"
],
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
"claim": "Gateway не требует access-JWT для /api/v1/auth/refresh и /api/v1/auth/logout; защищённый маршрут без токена получает 401. Полный цикл refresh/logout через реальный gateway проверяется отдельным P1 runtime-claim.",
"oracle": "JwtAuthenticationFilterTest: public paths пропущены, protected path без токена — 401",
"runtime": [],
"limitations": "Unit-тест проверяет маршрутизацию gateway; полный обмен пары и отзыв refresh — LDD-AUTH-008.",
"evidence": [
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()",
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()"
]
},
{
"id": "LDD-AUTH-006",
@@ -747,6 +749,231 @@
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
],
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
},
{
"id": "LDD-AUTH-008",
"title": "Refresh/logout через реальный gateway",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
"oracle": "runtime.auth_refresh",
"runtime": [
"runtime.auth_refresh"
],
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
},
{
"id": "LDD-ACCESS-010",
"title": "Черновики лабораторных скрыты от студентов",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Студент не видит черновик лабораторной ни в списке, ни в пакетных деталях, ни по прямому UUID; преподаватель и админ видят черновик.",
"oracle": "HTTP 200/404 и фильтрация списков",
"evidence": [
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id()",
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access a draft lab of own course()"
],
"limitations": "Проверены academic API и список; отдельный тест прогресса на сдачу черновика не добавлялся, так как academic отдаёт 404."
},
{
"id": "LDD-LIFE-004",
"title": "Академический отпуск: вход только для чтения",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.3",
"claim": "Студент в ACADEMIC_LEAVE может войти и читать, но не может сдавать лабораторные и начинать/завершать защиту.",
"oracle": "readOnly в login/me, ForbiddenException на submit/start/finish",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave login is read only()",
"ru.ysu.lms.progress.SubmissionServiceTest#academic leave student cannot submit()",
"ru.ysu.lms.progress.DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense()"
],
"limitations": "Запрет проверяется на progress-service; UI скрывает кнопки, но не является единственной защитой."
},
{
"id": "LDD-LIFE-005",
"title": "Действие «Убрать из группы» удалено",
"criticality": "P1",
"status": "active",
"requirement": "docs/14-stage4-report.md, §3",
"claim": "В админском UI и API больше нет отдельного действия unenroll; доступны только перевод, академ, отчисление, архив и восстановление.",
"oracle": "Отсутствие маршрута/кнопки/метода и отрицательные проверки шаблона",
"evidence": [
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders group lifecycle buttons by student status()",
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()"
],
"limitations": "Удалённый API-маршрут не проверяется отдельным HTTP-тестом; отсутствие метода компилятор проверяет по коду."
},
{
"id": "LDD-IMPORT-002",
"title": "Импорт не воскрешает неактивных студентов без флага",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Повторный импорт сохраняет EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE без явного флага restoreInactiveStatuses и сообщает строку в skipped; с флагом активирует и зачисляет.",
"oracle": "Статус/зачисление/ImportResult.skipped",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import does not resurrect expelled student without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import restores expelled student with explicit flag and records import history()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import preserves academic leave without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import still updates an active student()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import form requires explicit restoration flag()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import result shows skipped inactive rows()"
],
"limitations": "Флаг восстанавливает статус студента; архивный аккаунт может потребовать отдельного lifecycle-восстановления."
},
{
"id": "LDD-CONTENT-003",
"title": "Глобальные материалы STUDENTS/TEACHERS",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Аноним видит только ALL; студент — ALL+STUDENTS; преподаватель — ALL+TEACHERS; прямой доступ к чужой аудитории даёт 404; админ может создать/сменить аудиторию.",
"oracle": "Фильтрация дерева/статей по JWT-роли и authoring audience",
"evidence": [
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global tree is filtered by role audience()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global article access follows audience and role()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#authoring creates requested audience and rejects invalid()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#changing audience immediately changes access()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
],
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
},
{
"id": "LDD-IMPORT-003",
"title": "Ошибки импорта報告 по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Занятый логин и неактивная целевая группа возвращают applied=false с номером строки и не создают частичных студентов/зачислений.",
"oracle": "ImportResult.errors[].row и отсутствие записей после ошибки",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports occupied login with row and writes nothing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()"
],
"limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта."
},
{
"id": "LDD-UI-002",
"title": "Браузерные URL курсов используют courseNo",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.2; docs/09 §8",
"claim": "Страницы преподавателя, админа, материалы и профиль строят URL по courseNo, а REST API вызывают с UUID курса.",
"oracle": "Ссылки/redirects шаблонов и контроллеров содержат courseNo, API получает UUID",
"evidence": [
"ru.ysu.lms.web.teacher.TeacherCourseTemplateTest#course links use human course number instead of uuid()",
"ru.ysu.lms.web.teacher.TeacherCourseContentControllerTest#content page resolves course by human number and keeps uuid for api()",
"ru.ysu.lms.web.admin.AdminCourseControllerTest#detail page resolves course by number and calls api with uuid()",
"ru.ysu.lms.web.admin.AdminCourseTemplateTest#course page forms and actions use human number()",
"ru.ysu.lms.web.admin.AdminGroupControllerTest#teacher gradebook resolves course number and scopes api calls by uuid()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()"
],
"limitations": "Внутренние REST-контракты остаются UUID; form payload создания/копирования лабораторной тоже использует UUID."
},
{
"id": "LDD-PROFILE-002",
"title": "Профиль соответствует контракту docs/09 и ограничивает попытки",
"criticality": "P1",
"status": "active",
"requirement": "docs/09-teacher-student-profile.md, §3, §7",
"claim": "Профиль отдаёт variant.id, level.id/code, grade.updatedBy и author.name; число попыток отражается полностью, а список ограничивается параметром submissionLimit; профиль не делает запрос на каждую лабу.",
"oracle": "JSON-поля контракта; attemptsCount/submissionsTotal; число SQL-запросов не растёт с числом лабораторных",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher sees unpublished grade, authors and all submission attempts()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile limits submission attempts but reports total()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile uses one query per data kind instead of per lab()",
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()",
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#lookup includes student full name and leaves staff name empty()"
],
"limitations": "По умолчанию возвращаются последние 50 попыток; UI показывает, сколько скрыто."
},
{
"id": "LDD-GROUP-001",
"title": "Группа возвращается из архива",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.3",
"claim": "Архивную группу можно вернуть в ACTIVE; повторный возврат активной группы отклоняется, состав и история не меняются.",
"oracle": "status меняется ARCHIVED→ACTIVE; второй restore — 400; число студентов и история сохраняются",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#restores archived group and rejects repeated restore()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Возврат выполняется со страницы группы; отдельного массового восстановления нет."
},
{
"id": "LDD-GROUP-002",
"title": "Финальные ограничения когорты",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.2; docs/08-groups-courses-plan.md, этап 1",
"claim": "Все поля когорты NOT NULL, уникальность полная (без partial WHERE), legacy-строки блокируют миграцию с явной ошибкой.",
"oracle": "information_schema.is_nullable=NO для 5 полей; groups_cohort_unique без WHERE; master→branch migration PASS",
"evidence": [],
"runtime": [
"migration.master_upgrade"
],
"limitations": "Перед деплоем на реальных данных нужен ручной разбор legacy-групп; запрос для проверки есть в V14."
},
{
"id": "LDD-IMPORT-004",
"title": "XLSX-импорт отчитывается по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/08-groups-courses-plan.md, этап 1; docs/18 §3",
"claim": "Для XLSX занятый логин и неактивная целевая группа возвращают applied=false, номер строки и не создают частичных записей.",
"oracle": "ImportResult.errors.row=2, applied=false; студент не создан",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports occupied login before writing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports inactive target group before writing()"
],
"limitations": "Гонка с параллельной вставкой логина вне preflight всё ещё может дать общий 409."
},
{
"id": "LDD-AUDIT-006",
"title": "Операции с группами и студентами пишут аудит",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9; docs/08-groups-courses-plan.md, этап 1",
"claim": "GROUP_CREATE, STUDENT_CREATE, GROUP_ENROLL, GROUP_IMPORT, GROUP_ARCHIVE и GROUP_RESTORE попадают в журнал с актором и деталями.",
"oracle": "audit_events содержит все шесть action; actorAccountId/actorLogin заполнены",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Проверяются успешные операции; отказы валидации аудит не пишут."
},
{
"id": "LDD-PERF-003",
"title": "Пакетные сдачи и пагинация списков",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §10; docs/18 §3",
"claim": "Ведомость получает последние сдачи по всем выбранным лабам одним запросом; группы и плоский список материалов поддерживают limit/offset.",
"oracle": "одна выборка submissions на набор labId; страницы групп/материалов не пересекаются и не теряют записи",
"evidence": [
"ru.ysu.lms.progress.SubmissionBatchIntegrationTest#latest submissions are grouped by lab in one call()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#lists groups page by page and returns all without limit()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#admin article list supports offset and limit()"
],
"limitations": "Дерево курса по-прежнему загружается целиком для навигации; пагинируются плоские списки."
},
{
"id": "LDD-GROUP-003",
"title": "История членства видна на странице группы",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §3; docs/18 §3",
"claim": "Админ видит последние события membership_history по группе: студент, действие, откуда/куда, причина и время МСК.",
"oracle": "GET /api/v1/groups/{id}/history отдаёт studentName/fromGroupName/toGroupName; шаблон показывает блок истории",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#imports students and records membership history()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders membership history and restore action for archived group()"
],
"limitations": "Показаны последние 200 событий; фильтра/пагинации по истории нет."
}
]
}

View File

@@ -74,7 +74,10 @@ async function login(page: Page, login: string, password: string, expectedPath:
await page.goto("/login");
await page.fill("#login", login);
await page.fill("#password", password);
await Promise.all([page.waitForURL(expectedPath), page.click('button[type="submit"]')]);
await Promise.all([
page.waitForURL(expectedPath, { waitUntil: "domcontentloaded", timeout: 60_000 }),
page.click('button[type="submit"]'),
]);
}
test.beforeAll(async ({ playwright }) => {
@@ -155,23 +158,23 @@ test("teacher opens course, gradebook, student profile and authors a chapter", a
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await expect(page.locator("body")).toContainText(seeded.courseTitle);
await page.goto(`/teacher/courses/${seeded.courseId}`);
await page.goto(`/teacher/courses/${seeded.courseNo}`);
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText("E2E Lab");
await page.getByRole("link", { name: "Ведомость" }).click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${seeded.groupId}\\?courseId=`));
await expect(page).toHaveURL(new RegExp(`/admin/groups/${seeded.groupId}\\?courseNo=`));
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await expect(page.getByRole("button", { name: "Отчислить" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Сбросить" })).toHaveCount(0);
await page.getByRole("link", { name: seeded.studentFullName }).first().click();
await expect(page).toHaveURL(new RegExp(`/teacher/courses/${seeded.courseId}/students/`));
await expect(page).toHaveURL(new RegExp(`/teacher/courses/${seeded.courseNo}/students/`));
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await page.goto(`/teacher/courses/${seeded.courseId}/content`);
await page.goto(`/teacher/courses/${seeded.courseNo}/content`);
await page.fill("#chapterTitle", "E2E Chapter");
await page.getByRole("button", { name: "Создать главу" }).click();
await expect(page.locator("body")).toContainText("E2E Chapter");
@@ -200,7 +203,7 @@ test("student submits a solution and mobile teacher page renders", async ({ page
await page.context().clearCookies();
await page.setViewportSize({ width: 390, height: 844 });
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await page.goto(`/teacher/courses/${seeded.courseId}`);
await page.goto(`/teacher/courses/${seeded.courseNo}`);
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName);
});

View File

@@ -105,6 +105,7 @@ log "Применяю миграции ветки..."
cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql
cat services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql
cat services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql
cat services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql
echo 'SET search_path TO academic;'
cat services/academic-service/src/main/resources/db/migration/V6__courses.sql
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql
@@ -130,6 +131,13 @@ SELECT 'audit_keyset_idx=' || count(*) FROM pg_indexes
WHERE schemaname='identity' AND indexname='audit_events_occurred_id_idx';
SELECT 'students=' || count(*) FROM students;
SELECT 'groups=' || count(*) FROM groups;
SELECT 'cohort_not_null=' || count(*) FROM information_schema.columns
WHERE table_schema='identity' AND table_name='groups'
AND column_name IN ('stream','degree','course','number','enrollment_year')
AND is_nullable='NO';
SELECT 'cohort_unique_full=' || count(*) FROM pg_indexes
WHERE schemaname='identity' AND indexname='groups_cohort_unique'
AND indexdef NOT LIKE '%WHERE%';
SELECT 'history=' || count(*) FROM membership_history;
SET search_path TO academic;
SELECT 'labs_course_id=' || count(*) FROM information_schema.columns
@@ -151,21 +159,27 @@ SQL
)
RESULT=$(docker exec -i lms-postgres psql -U lms -d "$DB" -At -v ON_ERROR_STOP=1 -c "$CHECK_SQL")
# V13 повторно: идемпотентность.
# V13/V14 повторно: идемпотентность.
if { echo 'SET search_path TO progress;'; cat services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql; } | apply; then
V13_TWICE="passed"
else
V13_TWICE="failed"
FAILED=1
fi
if { echo 'SET search_path TO identity;'; cat services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql; } | apply; then
V14_TWICE="passed"
else
V14_TWICE="failed"
FAILED=1
fi
printf '%s\n' "$RESULT" | sed 's/^/ /'
python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" <<'PY'
python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" "$V14_TWICE" <<'PY'
import json
import sys
out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3]
out, result, v13, v14 = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
required = {
"audit_events=1",
"refresh_tokens=1",
@@ -173,6 +187,8 @@ required = {
"audit_keyset_idx=1",
"students=1",
"groups=1",
"cohort_not_null=5",
"cohort_unique_full=1",
"history=1",
"labs_course_id=1",
"chapters_course_id=1",
@@ -186,16 +202,19 @@ required = {
}
lines = {line.strip() for line in result.splitlines() if line.strip()}
missing = sorted(required - lines)
failed = bool(missing) or v13 != "passed"
failed = bool(missing) or v13 != "passed" or v14 != "passed"
if missing:
status = "failed"
detail = "postflight не подтвердил: " + ", ".join(missing)
elif v13 != "passed":
status = "failed"
detail = "V13 не применилась повторно"
elif v14 != "passed":
status = "failed"
detail = "V14 не применилась повторно"
else:
status = "passed"
detail = "master->branch: все postflight-проверки и повторная V13 успешны"
detail = "master->branch: postflight, повторные V13 и V14 успешны"
checks = [{"id": "migration.master_upgrade", "status": status, "detail": detail}]
with open(out, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ data class ChapterTreeNode(
val slug: String,
val position: Int,
val published: Boolean = true,
val audience: String? = null,
val sections: List<SectionTreeNode>,
)
@@ -115,19 +116,28 @@ class ContentService(
private val importer: ContentImporter,
private val assetStorage: AssetStorage,
) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
/**
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
* администратор — все аудитории. Курсовые главы здесь не показываются.
*/
@Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree =
buildTree(
fun tree(
includeHidden: Boolean = false,
role: String? = null,
): ContentTree {
val allowedAudiences = audiencesForRole(role)
return buildTree(
chapters
.findAllByOrderByPositionAscIdAsc()
.filter {
it.courseId == null &&
it.audience == AUDIENCE_ALL &&
it.audience in allowedAudiences &&
(includeHidden || it.published)
},
includeHidden = includeHidden,
)
}
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
@Transactional(readOnly = true)
@@ -150,9 +160,10 @@ class ContentService(
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
role: String? = null,
): ContentArticleView {
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
requirePublicChapter(ch, includeHidden)
requirePublicChapter(ch, includeHidden, role)
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
@@ -255,9 +266,10 @@ class ContentService(
private fun requirePublicChapter(
ch: ContentChapter,
includeHidden: Boolean,
role: String?,
) {
requireVisible(ch.published, includeHidden, "Глава скрыта")
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
}
private fun buildTree(
@@ -284,6 +296,7 @@ class ContentService(
slug = ch.slug,
position = ch.position,
published = ch.published,
audience = ch.audience,
sections =
(sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
@@ -346,24 +359,31 @@ class ContentService(
articles.save(a)
}
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> {
fun listAll(
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
val all =
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
if (limit <= 0) return all
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
}
@Transactional
@@ -372,6 +392,10 @@ class ContentService(
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
}
/**

View File

@@ -60,6 +60,11 @@ class ContentAdminController(
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
val audience: String? = null,
)
data class ChapterAudiencePayload(
val audience: String,
)
data class SectionCreatePayload(
@@ -153,19 +158,38 @@ class ContentAdminController(
}
@GetMapping("/articles")
fun list() = contentService.listAll()
fun list(
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(defaultValue = "0") limit: Int,
) = contentService.listAll(offset, limit)
@PostMapping("/chapters")
fun createChapter(
@RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug).also {
) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
details = "title=${body.title}",
details = "title=${body.title}, audience=${it.audience}",
)
}
@PutMapping("/chapters/{id}/audience")
fun chapterAudience(
@PathVariable id: UUID,
@RequestBody body: ChapterAudiencePayload,
@AuthenticationPrincipal jwt: Jwt,
) {
authoring.updateChapterAudience(id, body.audience)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_AUDIENCE",
ENTITY_CHAPTER,
id.toString(),
details = "audience=${body.audience}",
)
}

View File

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

View File

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

View File

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

View File

@@ -93,7 +93,22 @@ class CourseContentIntegrationTest {
}
@Test
fun `public tree contains only global chapters with ALL audience`() {
fun `admin article list supports offset and limit`() {
repeat(3) { seedMaterial(courseId = null, published = true) }
val all = contentService.listAll()
assertThat(all.size).isGreaterThanOrEqualTo(3)
val first = contentService.listAll(offset = 0, limit = 2)
val second = contentService.listAll(offset = 2, limit = 1)
assertThat(first).hasSize(2)
assertThat(second).hasSize(1)
assertThat(first.map { it.id }).doesNotContainAnyElementsOf(second.map { it.id })
assertThat(contentService.listAll(offset = 0, limit = 0)).hasSize(all.size)
}
@Test
fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
val course = newCourse()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,13 +17,15 @@ class GroupEntity(
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var name: String = "",
@Column(length = 50)
@Column(length = 50, nullable = false)
var stream: String? = null,
@Column(nullable = false)
var course: Short? = null,
@Column(nullable = false)
var number: Short? = null,
@Column(length = 2)
@Column(length = 2, nullable = false)
var degree: String? = null,
@Column(name = "enrollment_year")
@Column(name = "enrollment_year", nullable = false)
var enrollmentYear: Short? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)

View File

@@ -21,6 +21,8 @@ data class AccountInfoDto(
val id: String,
val login: String,
val role: String,
/** ФИО студента; для не-студенческих аккаунтов пусто. */
val fullName: String = "",
)
/** Группа с текущим составом — без учётных данных. */

View File

@@ -76,15 +76,25 @@ class InternalService(
)
}
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
/** Подписи авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true)
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
val requested = ids.map(::parseId).distinct()
if (requested.isEmpty()) return emptyList()
val found = accounts.findAllById(requested).associateBy { it.id }
val names =
students
.findAllById(requested)
.mapNotNull { student -> student.accountId?.let { it to student.fullName } }
.toMap()
return requested.mapNotNull { id ->
found[id]?.let { account ->
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
AccountInfoDto(
id = account.id.toString(),
login = account.login,
role = account.role.name,
fullName = names[account.id].orEmpty(),
)
}
}
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.GroupEntity
import java.util.UUID
@@ -20,4 +21,6 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findAllByOrderByNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(pageable: Pageable): List<GroupEntity>
}

View File

@@ -6,4 +6,9 @@ import java.util.UUID
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
fun findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(
toGroupId: UUID,
fromGroupId: UUID,
): List<MembershipHistory>
}

View File

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

View File

@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
@@ -19,6 +20,7 @@ import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Suppress("LongParameterList")
@@ -37,8 +39,26 @@ class GroupImportService(
filename: String,
bytes: ByteArray,
actorAccountId: UUID?,
restoreInactiveStatuses: Boolean = false,
): ImportResult {
val parsed = parser.parse(filename, bytes)
validationFailure(parsed)?.let { return it }
val cohort = parsed.cohort ?: return ImportResult(applied = false)
val group =
try {
findOrCreateGroup(cohort)
} catch (ex: ConflictException) {
return ImportResult(
applied = false,
groupName = cohort.name,
errors = listOf(ImportError(parsed.students.firstOrNull()?.row ?: 0, ex.message.orEmpty())),
)
}
return applyStudents(parsed, cohort, group, actorAccountId, restoreInactiveStatuses)
}
/** Ошибки, которые должны быть показаны до любой записи в БД. */
private fun validationFailure(parsed: ParsedGroup): ImportResult? {
if (parsed.errors.isNotEmpty()) {
return ImportResult(
applied = false,
@@ -53,13 +73,34 @@ class GroupImportService(
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
)
}
val group = findOrCreateGroup(cohort)
val preflight = preflightErrors(parsed)
return if (preflight.isEmpty()) null else ImportResult(applied = false, groupName = cohort.name, errors = preflight)
}
private fun applyStudents(
parsed: ParsedGroup,
cohort: GroupCohort,
group: GroupEntity,
actorAccountId: UUID?,
restoreInactiveStatuses: Boolean,
): ImportResult {
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
val skipped = mutableListOf<ImportError>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated)
moveToGroup(student, group, actorAccountId)
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
if (student.status == StudentStatus.ACTIVE) {
moveToGroup(student, group, actorAccountId)
} else {
skipped +=
ImportError(
row = parsedStudent.row,
message =
"Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
"статус и группа не изменены. Для восстановления включите явное восстановление " +
"отчисленных/выпущенных/архивных/академических студентов.",
)
}
}
return ImportResult(
applied = true,
@@ -67,9 +108,30 @@ class GroupImportService(
groupName = group.name,
added = added,
updated = updated,
skipped = skipped,
)
}
/**
* Проверки, которые нельзя откладывать до записи: иначе конфликт уникальности
* откатит весь импорт без номера строки. Здесь же ловим дубли внутри файла.
*/
private fun preflightErrors(parsed: ParsedGroup): List<ImportError> {
val errors = mutableListOf<ImportError>()
val seen = HashSet<String>()
parsed.students.forEach { student ->
if (!seen.add(student.studentNo)) {
errors += ImportError(student.row, "Студент ${student.studentNo} повторяется в файле")
return@forEach
}
val exists = students.findByStudentNo(student.studentNo) != null
if (!exists && accounts.existsByLogin(student.studentNo)) {
errors += ImportError(student.row, "Логин ${student.studentNo} уже занят другим аккаунтом")
}
}
return errors
}
private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity {
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
@@ -103,6 +165,7 @@ class GroupImportService(
cohort: GroupCohort,
added: MutableList<Credential>,
updated: MutableList<String>,
restoreInactiveStatuses: Boolean,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
@@ -111,8 +174,16 @@ class GroupImportService(
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
existing.account?.takeIf { it.status == AccountStatus.ARCHIVED }?.let { account ->
account.status = AccountStatus.ACTIVE
account.archivedAt = null
account.updatedAt = Instant.now()
accounts.save(account)
}
}
students.save(existing)
updated += parsed.studentNo
return existing

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.springframework.data.domain.PageRequest
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -34,8 +35,22 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val cohortResolver: GroupCohortResolver,
) {
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
fun list(
page: Int = 0,
limit: Int = 0,
): List<GroupSummary> {
val rows =
if (limit <= 0) {
groups.findAllByOrderByEnrollmentYearDescNameAsc()
} else {
groups.findAllByOrderByEnrollmentYearDescNameAsc(
PageRequest.of(page.coerceAtLeast(0), limit.coerceIn(1, MAX_GROUP_PAGE)),
)
}
return rows.map { summary(it) }
}
@Transactional
fun createStudent(
@@ -172,26 +187,6 @@ class GroupService(
return detail(groupId)
}
/** Убрать студента из группы; аккаунт и данные сохраняются. */
@Transactional
fun unenroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
val student = students.findById(studentId).orElse(null)
if (student != null) {
student.status = StudentStatus.EXPELLED
students.save(student)
}
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
return detail(groupId)
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
@@ -282,6 +277,43 @@ class GroupService(
return detail(groupId)
}
/** Последние события членства, связанные с группой (вход или выход). */
@Transactional(readOnly = true)
fun history(groupId: UUID): List<MembershipHistoryRow> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val events = history.findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(groupId, groupId)
val studentIds = events.map { it.studentId }.distinct()
val studentsById = students.findAllById(studentIds).associateBy { it.accountId }
val groupIds = events.flatMap { listOfNotNull(it.fromGroupId, it.toGroupId) }.distinct()
val groupsById = groups.findAllById(groupIds).associateBy { it.id }
return events.map { event ->
MembershipHistoryRow(
id = event.id.toString(),
studentId = event.studentId.toString(),
studentName = studentsById[event.studentId]?.fullName?.takeIf { it.isNotBlank() } ?: event.studentId.toString(),
fromGroupId = event.fromGroupId?.toString(),
fromGroupName = event.fromGroupId?.let { groupsById[it]?.name },
toGroupId = event.toGroupId?.toString(),
toGroupName = event.toGroupId?.let { groupsById[it]?.name },
action = event.action.name,
reason = event.reason,
at = event.at.toString(),
)
}
}
/** Возвращает архивную группу в работу; состав и история не меняются. */
@Transactional
fun restoreGroup(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ARCHIVED) {
throw BadRequestException("Группа ${group.name} не находится в архиве")
}
group.status = GroupStatus.ACTIVE
groups.save(group)
return detail(groupId)
}
/**
* Удаляет студента из identity: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
@@ -370,6 +402,10 @@ class GroupService(
}
}
private companion object {
const val MAX_GROUP_PAGE = 500
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {

View File

@@ -72,6 +72,7 @@ data class ImportResult(
val groupName: String? = null,
val added: List<Credential> = emptyList(),
val updated: List<String> = emptyList(),
val skipped: List<ImportError> = emptyList(),
val errors: List<ImportError> = emptyList(),
)
@@ -84,6 +85,20 @@ data class ResetPasswordResponse(
val password: String,
)
/** Событие истории членства для страницы группы. */
data class MembershipHistoryRow(
val id: String,
val studentId: String,
val studentName: String,
val fromGroupId: String?,
val fromGroupName: String?,
val toGroupId: String?,
val toGroupName: String?,
val action: String,
val reason: String,
val at: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,

View File

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

View File

@@ -0,0 +1,43 @@
-- Финальные ограничения когорты. Легаси-группы, которые не удалось разобрать
-- из имени в V5, должны быть исправлены вручную до применения этой миграции:
-- SELECT id, name, stream, degree, course, number, enrollment_year
-- FROM identity.groups
-- WHERE stream IS NULL OR degree IS NULL OR course IS NULL
-- OR number IS NULL OR enrollment_year IS NULL;
-- После UPDATE полей миграцию можно применить повторно.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM groups
WHERE stream IS NULL
OR degree IS NULL
OR course IS NULL
OR number IS NULL
OR enrollment_year IS NULL
) THEN
RAISE EXCEPTION
'Есть группы с незаполненной когортой; исправьте их перед V14 (см. комментарий в миграции)';
END IF;
END $$;
ALTER TABLE groups
ALTER COLUMN stream SET NOT NULL,
ALTER COLUMN degree SET NOT NULL,
ALTER COLUMN course SET NOT NULL,
ALTER COLUMN number SET NOT NULL,
ALTER COLUMN enrollment_year SET NOT NULL;
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_degree_check;
ALTER TABLE groups ADD CONSTRAINT groups_degree_check CHECK (degree IN ('БО', 'МО'));
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_course_range;
ALTER TABLE groups ADD CONSTRAINT groups_course_range CHECK (
(degree = 'БО' AND course BETWEEN 1 AND 4)
OR (degree = 'МО' AND course BETWEEN 1 AND 2)
);
-- Полная уникальность когорты: все поля уже NOT NULL.
DROP INDEX IF EXISTS groups_cohort_unique;
CREATE UNIQUE INDEX groups_cohort_unique
ON groups (stream, degree, number, course, enrollment_year);

View File

@@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
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
@@ -25,10 +29,13 @@ import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.GroupController
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.security.LmsRole
import java.nio.charset.StandardCharsets
import java.util.UUID
@@ -63,6 +70,8 @@ class AuditIntegrationTest {
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groupController: GroupController
@Autowired lateinit var context: WebApplicationContext
private val objectMapper = ObjectMapper()
@@ -378,6 +387,80 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
}
@Test
@Suppress("LongMethod")
fun `roster operations record group and student audit events`() {
val actor =
accounts
.save(
Account(
login = "audit.admin.${UUID.randomUUID().toString().take(8)}",
passwordHash = "x",
role = LmsRole.ADMIN,
mustChangePassword = false,
),
).id
val jwt =
Jwt
.withTokenValue("admin-token")
.header("alg", "none")
.subject(actor.toString())
.claim("login", "admin.audit")
.build()
val stream = "A" + UUID.randomUUID().toString().take(8)
SecurityContextHolder.getContext().authentication =
JwtAuthenticationToken(jwt, listOf(SimpleGrantedAuthority("ROLE_ADMIN")))
try {
val group =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 1, degree = "БО", enrollmentYear = 2025),
jwt,
)
val credential =
groupController.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(studentNo = "9901", lastName = "Аудитов", firstName = "Иван"),
jwt,
)
val target =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 2, degree = "БО", enrollmentYear = 2025),
jwt,
)
groupController.enroll(UUID.fromString(target.id), UUID.fromString(credential.id), jwt)
groupController.archive(UUID.fromString(target.id), jwt)
groupController.restore(UUID.fromString(target.id), jwt)
val csvBytes =
(
"поток;курс;номер;степень;год набора;ФИО;№;база\n" +
"$stream;1;3;БО;2025;Импортов Пётр;9902;бюджет"
).toByteArray(StandardCharsets.UTF_8)
groupController.import(MockMultipartFile("file", "group.csv", "text/csv", csvBytes), false, jwt)
val events = auditEvents.findTop200ByOrderByOccurredAtDesc()
assertTrue(
events
.map { it.action }
.toSet()
.containsAll(
setOf(
"GROUP_CREATE",
"STUDENT_CREATE",
"GROUP_ENROLL",
"GROUP_IMPORT",
"GROUP_ARCHIVE",
"GROUP_RESTORE",
),
),
)
val created = events.first { it.action == "GROUP_CREATE" && it.details.contains(group.name) }
assertEquals(actor, created.actorAccountId)
assertEquals("admin.audit", created.actorLogin)
} finally {
SecurityContextHolder.clearContext()
}
}
private data class AuditPage(
val details: List<String>,
val cursor: String?,

View File

@@ -130,6 +130,24 @@ class InternalControllerIntegrationTest {
}
}
@Test
fun `lookup includes student full name and leaves staff name empty`() {
val student = student(login = "a.ivanov", studentNo = "123456")
val staff = account("t.teacher")
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["${student.accountId}","${staff.id}"]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(2) }
jsonPath("$[0].fullName") { value("Иванов Иван Иванович") }
jsonPath("$[1].fullName") { value("") }
}
}
@Test
fun `lookup ignores missing accounts and duplicates`() {
val existing = account("a.ivanov")
@@ -275,8 +293,29 @@ class InternalControllerIntegrationTest {
@Test
fun `valid token returns groups sorted by name with status`() {
val graduated = groups.save(GroupEntity(name = "ПИ-22БО", enrollmentYear = 2025, status = GroupStatus.GRADUATED))
val active = groups.save(GroupEntity(name = "ПИ-21БО", enrollmentYear = 2025))
val graduated =
groups.save(
GroupEntity(
name = "ПИ-22БО",
stream = "ПИ",
course = 2,
number = 2,
degree = "БО",
enrollmentYear = 2025,
status = GroupStatus.GRADUATED,
),
)
val active =
groups.save(
GroupEntity(
name = "ПИ-21БО",
stream = "ПИ",
course = 2,
number = 1,
degree = "БО",
enrollmentYear = 2025,
),
)
mockMvc
.get("/api/v1/internal/groups") {
@@ -338,7 +377,19 @@ class InternalControllerIntegrationTest {
),
)
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
private fun group(name: String): GroupEntity {
val match = Regex("""-(\d)(\d+)(БО|МО)$""").find(name)
return groups.save(
GroupEntity(
name = name,
stream = "ПИ",
course = match?.groupValues?.get(1)?.toShortOrNull() ?: 1,
number = match?.groupValues?.get(2)?.toShortOrNull() ?: 1,
degree = match?.groupValues?.get(3) ?: "БО",
enrollmentYear = 2025,
),
)
}
private fun enroll(
group: GroupEntity,

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
@@ -12,16 +14,21 @@ import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.io.ByteArrayOutputStream
import java.util.UUID
@SpringBootTest
@@ -50,12 +57,16 @@ class GroupServiceIntegrationTest {
@Autowired lateinit var importService: GroupImportService
@Autowired lateinit var lifecycleService: StudentLifecycleService
@Autowired lateinit var authService: AuthService
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var students: StudentRepository
@Autowired lateinit var accounts: AccountRepository
@Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var history: MembershipHistoryRepository
@@ -81,6 +92,40 @@ class GroupServiceIntegrationTest {
assertTrue(error is ConflictException)
}
@Test
fun `creates two distinct cohorts in the same stream`() {
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))
assertEquals("ТЕСТ-11БО", first.name)
assertEquals("ТЕСТ-12БО", second.name)
}
@Test
fun `lists groups page by page and returns all without limit`() {
val stream = "P" + UUID.randomUUID().toString().take(8)
repeat(3) { index ->
groupService.create(
CreateGroupRequest(
stream = stream,
course = 1,
number = (index + 1).toShort(),
degree = "БО",
enrollmentYear = 2025,
),
)
}
val first = groupService.list(page = 0, limit = 2).filter { it.name.startsWith(stream) }
val second = groupService.list(page = 1, limit = 2).filter { it.name.startsWith(stream) }
val all = groupService.list().filter { it.name.startsWith(stream) }
assertEquals(2, first.size)
assertEquals(1, second.size)
assertEquals(3, all.size)
assertTrue(first.intersect(second.toSet()).isEmpty())
}
@Test
fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null)
@@ -98,6 +143,11 @@ class GroupServiceIntegrationTest {
assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action)
val groupHistory = groupService.history(groupId)
val imported = groupHistory.first { it.studentId == studentId.toString() }
assertEquals("IMPORT", imported.action)
assertEquals("Иванов Иван", imported.studentName)
assertEquals("ПИ-21БО", imported.toGroupName)
}
@Test
@@ -119,7 +169,7 @@ class GroupServiceIntegrationTest {
}
@Test
fun `transfer and expulsion keep history`() {
fun `transfer keeps history`() {
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
val imported = importService.import("group.csv", csv(validRows()), null)
@@ -137,9 +187,6 @@ class GroupServiceIntegrationTest {
)
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
groupService.unenroll(UUID.fromString(second.id), studentId, null)
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
assertTrue(imported.applied)
}
@@ -155,12 +202,221 @@ class GroupServiceIntegrationTest {
)
authService.login(LoginRequest("9001", credential.password))
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
lifecycleService.expel(UUID.fromString(credential.id), null, null)
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
assertTrue(error is UnauthorizedException)
}
@Test
fun `import does not resurrect expelled student without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.EXPELLED, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals("контракт", preserved.basis)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("EXPELLED"))
}
@Test
fun `import restores expelled student with explicit flag and records import history`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
restoreInactiveStatuses = true,
)
val restored = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertEquals(StudentStatus.ACTIVE, restored.status)
assertEquals(2024.toShort(), restored.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(MembershipAction.IMPORT, history.findByStudentIdOrderByAtDesc(studentId).first().action)
}
@Test
fun `import preserves academic leave without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.ACADEMIC_LEAVE
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.ACADEMIC_LEAVE, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
}
@Test
fun `import reports occupied login with row and writes nothing`() {
accounts.saveAndFlush(
Account(
login = "3001",
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result =
importService.import(
"group.csv",
csv("ПИ;1;9;БО;2026;Иванов Иван;3001;бюджет"),
null,
)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
val error = result.errors.single()
assertEquals(2, error.row)
assertTrue(error.message.contains("Логин 3001"))
assertNull(students.findByStudentNo("3001"))
}
@Test
fun `restores archived group and rejects repeated restore`() {
val created = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 7, degree = "БО", enrollmentYear = 2026))
val id = UUID.fromString(created.id)
assertEquals("ARCHIVED", groupService.archiveGroup(id).status)
assertEquals("ACTIVE", groupService.restoreGroup(id).status)
val error = runCatching { groupService.restoreGroup(id) }.exceptionOrNull()
assertTrue(error is BadRequestException)
}
@Test
fun `xlsx import reports occupied login before writing`() {
val studentNo = "X" + UUID.randomUUID().toString().take(8)
accounts.save(
Account(
login = studentNo,
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result = importService.import("group.xlsx", xlsx("ПИ;1;4;БО;2026;Иванов Иван;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("Логин $studentNo"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `xlsx import reports inactive target group before writing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 5, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val studentNo = "Y" + UUID.randomUUID().toString().take(8)
val result = importService.import("group.xlsx", xlsx("ПИ;1;5;БО;2026;Петров Пётр;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("не активна"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val result =
importService.import(
"group.csv",
csv("ПИ;1;8;БО;2026;Иванов Иван;3002;бюджет"),
null,
)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
val error = result.errors.single()
assertEquals(2, error.row)
assertTrue(error.message.contains("не активна"))
assertNull(students.findByStudentNo("3002"))
}
@Test
fun `import still updates an active student`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван Иванович;2001;контракт"),
null,
)
val updated = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertTrue(result.updated.contains("2001"))
assertEquals(StudentStatus.ACTIVE, updated.status)
assertEquals("Иванов Иван Иванович", updated.fullName)
assertEquals("контракт", updated.basis)
assertEquals(2024.toShort(), updated.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
}
private fun validRows(): String =
"""
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
@@ -168,4 +424,22 @@ class GroupServiceIntegrationTest {
""".trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
private fun xlsx(row: String): ByteArray {
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
val values = row.split(";")
return XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("group")
sheet.createRow(0).apply {
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
}
sheet.createRow(1).apply {
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
}
ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,7 @@ data class AccountInfo(
val id: String = "",
val login: String = "",
val role: String = "",
val fullName: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -20,8 +20,9 @@ class GroupLabDataService(
groupId: UUID,
labIds: List<UUID>,
token: String,
): GroupLabDataResponse =
GroupLabDataResponse(
): GroupLabDataResponse {
val latestSubmissions = submissions.latestPerStudentByLab(labIds)
return GroupLabDataResponse(
groupId = groupId.toString(),
labs =
labIds.map { labId ->
@@ -37,10 +38,11 @@ class GroupLabDataService(
manual = sll.manual,
)
},
submissions = submissions.latestPerStudent(labId),
submissions = latestSubmissions[labId].orEmpty(),
defenses = defenses.listForLab(labId),
publication = publications.state(groupId, labId),
)
},
)
}
}

View File

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

View File

@@ -19,6 +19,7 @@ class SubmissionService(
private val submissions: SubmissionRepository,
private val storage: SubmissionStorage,
private val academic: AcademicClient,
private val writeGuard: StudentWriteGuard,
) {
@Transactional
fun submit(
@@ -27,6 +28,7 @@ class SubmissionService(
files: List<MultipartFile>,
token: String,
): SubmissionDto {
writeGuard.ensureWritable(studentId)
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
@@ -157,6 +159,19 @@ class SubmissionService(
return perStudent.values.map { toDto(it, filesOf(it)) }
}
/** Последняя сдача каждого студента по нескольким лабораторным — одним запросом. */
@Transactional(readOnly = true)
fun latestPerStudentByLab(labIds: Collection<UUID>): Map<UUID, List<SubmissionDto>> {
if (labIds.isEmpty()) return emptyMap()
val perLabAndStudent = mutableMapOf<Pair<UUID, UUID>, Submission>()
for (s in submissions.findByLabIdInOrderByCreatedAtDesc(labIds)) {
perLabAndStudent.putIfAbsent(s.labId to s.studentId, s)
}
return perLabAndStudent.values
.groupBy { it.labId }
.mapValues { (_, rows) -> rows.map { toDto(it, filesOf(it)) } }
}
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")

View File

@@ -9,6 +9,7 @@ import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.progress.audit.AuditClient
import java.net.URLEncoder
@@ -26,6 +27,7 @@ class ProfileController(
fun profile(
@PathVariable courseId: UUID,
@PathVariable studentId: UUID,
@RequestParam(defaultValue = "50") submissionLimit: Int,
@AuthenticationPrincipal jwt: Jwt,
): StudentCourseProfile {
val profile =
@@ -35,6 +37,7 @@ class ProfileController(
callerId = UUID.fromString(jwt.subject),
callerRole = role(jwt),
token = jwt.tokenValue,
submissionLimit = submissionLimit,
)
// Свой профиль студент открывает часто; аудит нужен для Staff-просмотров,
// чтобы не нагружать identity на горячем пути и не засорять журнал.

View File

@@ -29,24 +29,28 @@ data class ProfileSummary(
)
data class ProfileVariant(
val id: String,
val number: String,
val title: String,
)
data class ProfileLevel(
val id: String,
val code: String,
val title: String,
)
/** Подпись автора оценки/комментария: ФИО, при отсутствии — логин. */
data class ProfileAuthor(
val id: String,
val login: String,
val name: String,
)
data class ProfileGrade(
val score: String,
val published: Boolean,
val generalComment: String,
val author: ProfileAuthor?,
val updatedBy: ProfileAuthor?,
)
data class ProfileComment(
@@ -86,6 +90,8 @@ data class ProfileLab(
val grade: ProfileGrade?,
val comments: List<ProfileComment>,
val defense: ProfileDefense?,
/** Всего попыток; [submissions] может быть ограничен для больших курсов. */
val submissionsTotal: Int = 0,
val submissions: List<ProfileSubmission>,
)

View File

@@ -57,13 +57,18 @@ class StudentProfileService(
callerId: UUID,
callerRole: String,
token: String,
submissionLimit: Int = DEFAULT_SUBMISSION_LIMIT,
): StudentCourseProfile {
val role = normalizeRole(callerRole)
val effectiveLimit = if (submissionLimit <= 0) Int.MAX_VALUE else submissionLimit.coerceIn(1, MAX_SUBMISSION_LIMIT)
val course = academic.getCourse(courseId.toString(), token)
val context = identity.getStudentContext(studentId.toString())
requireProfileAccess(course, context, studentId, callerId, role)
val labs = courseLabs(courseId, token)
val labs =
courseLabs(courseId, token).filter { (_, lab) ->
role != ROLE_STUDENT || lab.status == "published"
}
val labIds = labs.map { it.first }
val sid = studentId
val visibleGrades = gradesByLab(sid, labIds)
@@ -82,7 +87,7 @@ class StudentProfileService(
allComments[labId].orEmpty().forEach { it.authorAccountId?.let(authorIds::add) }
}
}
val logins = lookupLogins(authorIds)
val authors = lookupAuthors(authorIds)
val profileLabs =
labs.map { (labId, lab) ->
@@ -99,7 +104,8 @@ class StudentProfileService(
levelId = levelsByLab[labId],
published = labId in published,
showUnpublished = showUnpublished,
logins = logins,
authors = authors,
submissionLimit = effectiveLimit,
)
}
@@ -263,12 +269,15 @@ class StudentProfileService(
return all
}
private fun lookupLogins(authorIds: Set<UUID>): Map<String, String> {
private fun lookupAuthors(authorIds: Set<UUID>): Map<String, ProfileAuthor> {
if (authorIds.isEmpty()) return emptyMap()
val accounts = runCatching { identity.lookupAccounts(authorIds.map(UUID::toString)) }.getOrDefault(emptyList())
return accounts
.filter { it.id.isNotBlank() }
.associate { it.id to it.login.ifBlank { UNKNOWN_AUTHOR } }
.associate { account ->
val name = account.fullName.ifBlank { account.login }.ifBlank { UNKNOWN_AUTHOR }
account.id to ProfileAuthor(account.id, name)
}
}
@Suppress("LongParameterList")
@@ -285,12 +294,14 @@ class StudentProfileService(
levelId: UUID?,
published: Boolean,
showUnpublished: Boolean,
logins: Map<String, String>,
authors: Map<String, ProfileAuthor>,
submissionLimit: Int,
): ProfileLab {
val canSeeGrade = showUnpublished || published
val sortedSubmissions = submissions.sortedByDescending { it.seq }
val allSubmissions = submissions.sortedByDescending { it.seq }
val sortedSubmissions = allSubmissions.take(submissionLimit)
val lastSubmissionAt =
sortedSubmissions.maxWithOrNull(compareBy<Submission> { it.createdAt }.thenBy { it.seq })?.createdAt?.toString()
allSubmissions.maxWithOrNull(compareBy<Submission> { it.createdAt }.thenBy { it.seq })?.createdAt?.toString()
val gradeDto =
grade
?.takeIf { canSeeGrade }
@@ -299,7 +310,7 @@ class StudentProfileService(
score = it.score.toPlainString(),
published = published,
generalComment = it.generalComment,
author = authorOf(it.updatedByAccountId, logins),
updatedBy = authorOf(it.updatedByAccountId, authors),
)
}
val commentDtos =
@@ -311,7 +322,7 @@ class StudentProfileService(
id = it.id.toString(),
text = it.text,
tone = it.tone,
author = authorOf(it.authorAccountId, logins),
author = authorOf(it.authorAccountId, authors),
createdAt = it.createdAt.toString(),
)
}
@@ -325,11 +336,12 @@ class StudentProfileService(
deadlineAt = lab.deadlineAt,
variant = variantOf(lab, variantId),
level = levelOf(lab, levelId),
attemptsCount = sortedSubmissions.size,
attemptsCount = allSubmissions.size,
lastSubmissionAt = lastSubmissionAt,
grade = gradeDto,
comments = commentDtos,
defense = defense?.let { toDefense(it) },
submissionsTotal = allSubmissions.size,
submissions =
sortedSubmissions.map { s ->
ProfileSubmission(
@@ -354,7 +366,7 @@ class StudentProfileService(
.flatMap { it.variants.asSequence() }
.firstOrNull { it.id == variantId.toString() }
?: return null
return ProfileVariant(variant.number, variant.title)
return ProfileVariant(variant.id.ifBlank { variantId.toString() }, variant.number, variant.title)
}
private fun levelOf(
@@ -363,7 +375,7 @@ class StudentProfileService(
): ProfileLevel? {
if (levelId == null) return null
val level = lab.levels.firstOrNull { it.id == levelId.toString() } ?: return null
return ProfileLevel(level.title)
return ProfileLevel(level.id.ifBlank { levelId.toString() }, level.code, level.title)
}
private fun toDefense(session: DefenseSession): ProfileDefense {
@@ -419,12 +431,8 @@ class StudentProfileService(
private fun authorOf(
authorId: UUID?,
logins: Map<String, String>,
): ProfileAuthor? =
authorId?.let {
val key = it.toString()
ProfileAuthor(key, logins[key] ?: UNKNOWN_AUTHOR)
}
authors: Map<String, ProfileAuthor>,
): ProfileAuthor? = authorId?.let { authors[it.toString()] ?: ProfileAuthor(it.toString(), UNKNOWN_AUTHOR) }
private fun normalizeRole(role: String): String = role.removePrefix("ROLE_").uppercase()
@@ -433,5 +441,7 @@ class StudentProfileService(
const val ROLE_TEACHER = "TEACHER"
const val ROLE_STUDENT = "STUDENT"
const val UNKNOWN_AUTHOR = "—"
const val DEFAULT_SUBMISSION_LIMIT = 50
const val MAX_SUBMISSION_LIMIT = 500
}
}

View File

@@ -19,6 +19,8 @@ interface SubmissionRepository : JpaRepository<Submission, UUID> {
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
fun findByLabIdInOrderByCreatedAtDesc(labIds: Collection<UUID>): List<Submission>
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,

View File

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

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.progress
import jakarta.persistence.EntityManagerFactory
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.`when`
@@ -31,7 +33,12 @@ import java.util.UUID
* Проверяются доступ, публикация оценок, все попытки сдач и скачивание.
*/
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-profile-submissions"])
@SpringBootTest(
properties = [
"lms.submissions.dir=build/test-profile-submissions",
"spring.jpa.properties.hibernate.generate_statistics=true",
],
)
class StudentProfileIntegrationTest {
companion object {
@Container
@@ -65,6 +72,9 @@ class StudentProfileIntegrationTest {
@Autowired
private lateinit var storage: SubmissionStorage
@Autowired
private lateinit var entityManagerFactory: EntityManagerFactory
@MockitoBean
private lateinit var academic: AcademicClient
@@ -72,6 +82,25 @@ class StudentProfileIntegrationTest {
private lateinit var identity: IdentityClient
@Test
fun `profile uses one query per data kind instead of per lab`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labs = (1..10).map { labInfo(UUID.randomUUID(), it, UUID.randomUUID(), UUID.randomUUID()) }
stubCourse(courseId, groupId, TEACHER_TOKEN, labs)
stubStudent(studentId, groupId)
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(profile.labs).hasSize(10)
assertThat(statistics.prepareStatementCount).isLessThanOrEqualTo(15)
}
@Test
@Suppress("LongMethod")
fun `teacher sees unpublished grade, authors and all submission attempts`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
@@ -111,9 +140,13 @@ class StudentProfileIntegrationTest {
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
val lab = profile.labs.single()
assertThat(lab.variant?.id).isEqualTo(variantId.toString())
assertThat(lab.variant?.number).isEqualTo("7")
assertThat(lab.level?.id).isEqualTo(levelId.toString())
assertThat(lab.level?.code).isEqualTo("base")
assertThat(lab.level?.title).isEqualTo("Базовый")
assertThat(lab.attemptsCount).isEqualTo(2)
assertThat(lab.submissionsTotal).isEqualTo(2)
assertThat(lab.submissions.map { it.seq }).containsExactly(2, 1)
assertThat(lab.submissions.first().files).containsExactlyInAnyOrder("main.c", "README.md")
assertThat(lab.submissions.first().downloadUrl)
@@ -121,10 +154,10 @@ class StudentProfileIntegrationTest {
val grade = checkNotNull(lab.grade)
assertThat(grade.score).isEqualTo("4.5")
assertThat(grade.published).isFalse()
assertThat(grade.author?.login).isEqualTo("teacher_login")
assertThat(grade.updatedBy?.name).isEqualTo("teacher_login")
assertThat(lab.comments).hasSize(1)
val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login")
assertThat(commentAuthor?.name).isEqualTo("teacher_login")
val download =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
@@ -236,6 +269,31 @@ class StudentProfileIntegrationTest {
assertThat(download.filename).endsWith(".zip")
}
@Test
fun `profile limits submission attempts but reports total`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labId = UUID.randomUUID()
stubCourse(courseId, groupId, TEACHER_TOKEN, listOf(labInfo(labId, 1, UUID.randomUUID(), UUID.randomUUID())))
stubStudent(studentId, groupId)
saveSubmission(studentId, labId, 1, "2026-10-01T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 2, "2026-10-02T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 3, "2026-10-03T10:00:00Z", "main.c")
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", TEACHER_TOKEN, submissionLimit = 1)
val lab = profile.labs.single()
assertThat(lab.submissionsTotal).isEqualTo(3)
assertThat(lab.attemptsCount).isEqualTo(3)
assertThat(lab.submissions.map { it.seq }).containsExactly(3)
val unlimited = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", TEACHER_TOKEN, submissionLimit = 0)
val unlimitedLab = unlimited.labs.single()
assertThat(unlimitedLab.submissions.map { it.seq }).containsExactly(3, 2, 1)
}
private fun stubCourse(
courseId: UUID,
groupId: UUID,
@@ -287,6 +345,7 @@ class StudentProfileIntegrationTest {
id = labId.toString(),
number = number,
title = "Лабораторная $number",
status = "published",
deadlineAt = "2026-12-01T00:00:00Z",
levels =
listOf(

View File

@@ -0,0 +1,63 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
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.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.time.Instant
import java.util.UUID
/** Пакетная выборка последних сдач по нескольким лабораторным для ведомости. */
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-batch-submissions"])
class SubmissionBatchIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var service: SubmissionService
@Autowired
private lateinit var submissions: SubmissionRepository
@Test
fun `latest submissions are grouped by lab in one call`() {
val student = UUID.randomUUID()
val otherStudent = UUID.randomUUID()
val firstLab = UUID.randomUUID()
val secondLab = UUID.randomUUID()
submissions.save(submission(student, firstLab, seq = 1, at = "2026-10-01T10:00:00Z"))
submissions.save(submission(student, firstLab, seq = 2, at = "2026-10-02T10:00:00Z"))
submissions.save(submission(otherStudent, firstLab, seq = 1, at = "2026-10-03T10:00:00Z"))
submissions.save(submission(student, secondLab, seq = 1, at = "2026-10-04T10:00:00Z"))
val result = service.latestPerStudentByLab(listOf(firstLab, secondLab))
assertThat(result.keys).containsExactlyInAnyOrder(firstLab, secondLab)
assertThat(result.getValue(firstLab).map { it.seq }).containsExactlyInAnyOrder(2, 1)
assertThat(result.getValue(secondLab).map { it.seq }).containsExactly(1)
}
@Test
fun `empty lab list returns empty map`() {
assertThat(service.latestPerStudentByLab(emptyList())).isEmpty()
}
private fun submission(
studentId: UUID,
labId: UUID,
seq: Int,
at: String,
): Submission = Submission(studentId = studentId, labId = labId, seq = seq, createdAt = Instant.parse(at))
}

View File

@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Path
@@ -33,7 +34,9 @@ class SubmissionServiceTest {
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
val storage = SubmissionStorage(tempDir.toString())
return SubmissionService(repo, storage, academic) to repo
val guard = mockk<StudentWriteGuard>()
every { guard.ensureWritable(any()) } returns Unit
return SubmissionService(repo, storage, academic, guard) to repo
}
@Test
@@ -53,6 +56,20 @@ class SubmissionServiceTest {
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
}
@Test
fun `academic leave student cannot submit`() {
val repo = mockk<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
fun `rejects xml project file renamed to source extension`() {
val (service, _) = service()

View File

@@ -21,7 +21,7 @@ import ru.ysu.lms.web.api.CourseUpdatePayload
import ru.ysu.lms.web.api.assignCourseTeacher
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.bootstrapCourses
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.createCourse
import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listCourses
@@ -61,7 +61,7 @@ class AdminCourseController(
try {
val course = api.createCourse(token(session), CourseCreatePayload(form.title?.trim()?.takeIf { it.isNotBlank() }))
redirect.addFlashAttribute("notice", "Курс №${course.courseNo} создан")
"redirect:/admin/courses/${course.id}"
"redirect:/admin/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses"
@@ -105,14 +105,14 @@ class AdminCourseController(
if (chaptersWithoutCourse > 0) append("; глав без курса: $chaptersWithoutCourse")
}
@GetMapping("/{id}")
@GetMapping("/{courseNo}")
fun detail(
@PathVariable id: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val course = api.courseDetail(t, id)
val course = api.courseByUrlNo(t, courseNo)
val groups = api.listGroups(t)
val attachedGroups = course.groups.filter { it.removedAt == null }
val attachedIds = attachedGroups.map { it.groupId }.toSet()
@@ -127,92 +127,110 @@ class AdminCourseController(
model.addAttribute("availableTeachers", teachers.filter { it.id !in activeTeacherIds })
model.addAttribute("teacherNames", teachers.associate { it.id to teacherLabel(it.name, it.login) })
model.addAttribute("activeTeachers", activeTeachers)
model.addAttribute("labs", api.listLabs(t, id))
model.addAttribute("labs", api.listLabs(t, course.id))
return "admin/course"
}
@PostMapping("/{id}/groups")
@PostMapping("/{courseNo}/groups")
fun attachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@ModelAttribute form: CourseGroupForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.attachCourseGroup(token(session), id, CourseGroupPayload(requireGroupId(form)))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.attachCourseGroup(t, course.id, CourseGroupPayload(requireGroupId(form)))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/groups/{groupId}/detach")
@PostMapping("/{courseNo}/groups/{groupId}/detach")
fun detachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@PathVariable groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.detachCourseGroup(token(session), id, groupId)
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.detachCourseGroup(t, course.id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/teachers")
@PostMapping("/{courseNo}/teachers")
fun assignTeacher(
@PathVariable id: String,
@PathVariable courseNo: String,
@ModelAttribute form: CourseTeacherForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignCourseTeacher(token(session), id, CourseTeacherPayload(requireAccountId(form)))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.assignCourseTeacher(t, course.id, CourseTeacherPayload(requireAccountId(form)))
redirect.addFlashAttribute("notice", "Преподаватель назначен на курс")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/teachers/{accountId}/remove")
@PostMapping("/{courseNo}/teachers/{accountId}/remove")
fun removeTeacher(
@PathVariable id: String,
@PathVariable courseNo: String,
@PathVariable accountId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.removeCourseTeacher(token(session), id, accountId)
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.removeCourseTeacher(t, course.id, accountId)
redirect.addFlashAttribute("notice", "Преподаватель снят с курса")
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
@PostMapping("/{id}/status")
@PostMapping("/{courseNo}/status")
fun updateStatus(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam status: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val normalized = normalizeStatus(status)
api.updateCourse(token(session), id, CourseUpdatePayload(status = normalized))
val t = token(session)
val course = api.courseByUrlNo(t, courseNo)
api.updateCourse(t, course.id, CourseUpdatePayload(status = normalized))
val label = if (normalized == "ARCHIVED") "Курс перемещён в архив" else "Курс возвращён в работу"
redirect.addFlashAttribute("notice", label)
"redirect:/admin/courses/$id"
courseRedirect(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/courses/$id"
courseRedirect(courseNo)
}
/** Возврат на страницу курса по человекочитаемому номеру; некорректный номер — в список курсов. */
private fun courseRedirect(courseNo: Long): String = "redirect:/admin/courses/$courseNo"
private fun courseRedirect(rawCourseNo: String): String {
val courseNo = rawCourseNo.trim().toLongOrNull() ?: return "redirect:/admin/courses"
return courseRedirect(courseNo)
}
private fun requireGroupId(form: CourseGroupForm): String = form.groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
private fun requireAccountId(form: CourseTeacherForm): String =

View File

@@ -62,17 +62,17 @@ class AdminDefenseController(
fun assignDefense(
@PathVariable labId: String,
@ModelAttribute form: DefenseForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
}
/** Назначение/правка индивидуального задания со страницы группы. */
@@ -80,17 +80,17 @@ class AdminDefenseController(
fun saveDefense(
@ModelAttribute form: DefenseForm,
@RequestParam labId: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.assignDefense(token(session), form.studentId, labId, form.assignmentMd)
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(form.groupId, labId, courseId)
redirectToGroup(form.groupId, labId, courseNo)
}
@PostMapping("/labs/{labId}/defense/import")
@@ -119,17 +119,17 @@ class AdminDefenseController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) groupId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val code = api.issueDefenseCode(token(session), id)
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
}
@Suppress("LongParameterList")
@@ -138,16 +138,16 @@ class AdminDefenseController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) groupId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.resetDefense(token(session), id)
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectToGroup(groupId, labId, courseId)
redirectToGroup(groupId, labId, courseNo)
}
}

View File

@@ -31,34 +31,34 @@ class AdminDistributeController(
fun setStudentLevel(
@PathVariable gid: String,
@ModelAttribute form: StudentLevelForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId))
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
@PostMapping("/groups/{gid}/pin-variant")
fun pinStudentVariant(
@PathVariable gid: String,
@ModelAttribute form: PinVariantForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId))
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
@Suppress("LongParameterList")
@@ -67,7 +67,7 @@ class AdminDistributeController(
@PathVariable gid: String,
@RequestParam labId: String,
@RequestParam(defaultValue = "false") force: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -78,10 +78,10 @@ class AdminDistributeController(
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
)
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
@@ -89,7 +89,7 @@ class AdminDistributeController(
fun distributeAllForGroup(
@PathVariable gid: String,
@RequestParam(defaultValue = "false") force: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -98,7 +98,7 @@ class AdminDistributeController(
val labs = api.listLabs(t).filter { it.status == "published" }
if (labs.isEmpty()) {
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
return groupRedirect(gid, courseId)
return groupRedirect(gid, courseNo)
}
val result =
api.distribute(
@@ -106,10 +106,10 @@ class AdminDistributeController(
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
)
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(gid, courseId)
groupRedirect(gid, courseNo)
}
/** Массовая установка уровня выделенным студентам. */

View File

@@ -10,6 +10,7 @@ import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.archiveGroup
import ru.ysu.lms.web.api.restoreGroup
import tools.jackson.databind.ObjectMapper
/** Архив группы без массового изменения статусов студентов. */
@@ -20,6 +21,21 @@ class AdminGroupArchiveController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.restoreGroup(token(session), id)
redirect.addFlashAttribute("notice", "Группа возвращена из архива")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: String,

View File

@@ -17,8 +17,10 @@ import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeDto
@@ -26,13 +28,14 @@ import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.LabDataDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.MembershipHistoryDto
import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.PublicationStateDto
import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate
@@ -41,6 +44,7 @@ import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.getLevels
import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupHistory
import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDetail
@@ -71,6 +75,10 @@ class AdminGroupController(
) : AdminSupport(api, markdown, mapper) {
private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
private companion object {
const val GROUP_PAGE_SIZE = 50
}
@GetMapping
fun dashboard(
session: HttpSession,
@@ -100,10 +108,16 @@ class AdminGroupController(
@GetMapping("/groups")
fun groups(
@RequestParam(defaultValue = "0") page: Int,
session: HttpSession,
model: Model,
): String {
model.addAttribute("groups", api.listGroups(token(session)))
val safePage = page.coerceAtLeast(0)
val loaded = api.listGroups(token(session), safePage, GROUP_PAGE_SIZE + 1)
val rows = loaded.take(GROUP_PAGE_SIZE)
model.addAttribute("groups", rows)
model.addAttribute("page", safePage)
model.addAttribute("hasNext", loaded.size > GROUP_PAGE_SIZE)
return "admin/groups"
}
@@ -135,12 +149,19 @@ class AdminGroupController(
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
val result =
api.importGroup(
token(session),
file.originalFilename ?: "group.xlsx",
file.bytes,
restoreInactiveStatuses,
)
redirect.addFlashAttribute("importResult", result)
if (!result.applied) {
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
@@ -156,25 +177,34 @@ class AdminGroupController(
@GetMapping("/groups/{id}")
fun groupDetail(
@PathVariable id: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val teacher = isTeacher(session)
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
val course = resolveCourse(t, courseNo)
val view =
if (teacher) {
teacherGroupView(t, id, effectiveCourseId)
teacherGroupView(t, id, course)
} else {
adminGroupView(t, id)
}
fillGroupModel(model, view)
model.addAttribute("teacherView", teacher)
model.addAttribute("profileCourseId", effectiveCourseId)
model.addAttribute("profileCourseNo", course?.courseNo)
return "admin/group"
}
private fun resolveCourse(
token: String,
rawCourseNo: String?,
): CourseDetailDto? =
rawCourseNo
?.trim()
?.takeIf { it.isNotBlank() }
?.let { api.courseByUrlNo(token, it) }
/** Администратор видит все группы и аттестации; преподаватель — только свой курс. */
private fun adminGroupView(
token: String,
@@ -186,7 +216,11 @@ class AdminGroupController(
val grades = api.listGrades(token)
val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates)
val history =
runCatching { api.groupHistory(token, id) }
.getOrDefault(emptyList())
.onEach { it.atMsk = DateUtils.formatMsk(it.at) }
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates, history)
}
/**
@@ -196,11 +230,10 @@ class AdminGroupController(
private fun teacherGroupView(
token: String,
id: String,
courseId: String?,
course: CourseDetailDto?,
): GroupViewModel {
if (courseId == null) throw ApiException(400, "Не указан курс")
api.courseDetail(token, courseId)
val roster = api.courseGroupStudents(token, courseId, id)
if (course == null) throw ApiException(400, "Не указан курс")
val roster = api.courseGroupStudents(token, course.id, id)
val group =
GroupDetailDto(
id = id,
@@ -215,17 +248,17 @@ class AdminGroupController(
)
},
)
val labs = api.listLabs(token).filter { it.courseId == courseId && it.status == "published" }
val labs = api.listLabs(token).filter { it.courseId == course.id && it.status == "published" }
val labIds = labs.map { it.id }.toSet()
val memberIds = group.students.map { it.id }.toSet()
val grades = api.listGrades(token, courseId).filter { it.studentId in memberIds && it.labId in labIds }
val grades = api.listGrades(token, course.id).filter { it.studentId in memberIds && it.labId in labIds }
val dataByLab =
if (labs.isEmpty()) {
emptyMap()
} else {
api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
}
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList())
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList(), emptyList())
}
@Suppress("LongParameterList")
@@ -236,6 +269,7 @@ class AdminGroupController(
dataByLab: Map<String, LabDataDto>,
attestations: List<GroupAttestation>,
candidates: List<StudentCandidateDto>,
history: List<MembershipHistoryDto>,
): GroupViewModel {
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -291,6 +325,7 @@ class AdminGroupController(
labPublication,
submissionCounts,
attestations,
history,
)
}
@@ -386,6 +421,7 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val history: List<MembershipHistoryDto>,
)
private fun fillGroupModel(
@@ -399,6 +435,7 @@ class AdminGroupController(
model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("history", view.history)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
}
@@ -407,13 +444,14 @@ class AdminGroupController(
fun assignModal(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher)
val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val lab = api.labDetail(t, labId)
val levels = api.getLevels(t, labId).associateBy { it.studentId }
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
@@ -439,12 +477,7 @@ class AdminGroupController(
model.addAttribute("groupId", id)
model.addAttribute("rows", rows)
model.addAttribute("variantUsage", variantUsage)
val courseQuery =
courseId
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "&courseId=$it" }
.orEmpty()
val courseQuery = course?.let { "&courseNo=${it.courseNo}" }.orEmpty()
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery")
return "admin/assign-fragment"
}
@@ -455,15 +488,12 @@ class AdminGroupController(
private fun groupStudentsFor(
token: String,
groupId: String,
courseId: String?,
course: CourseDetailDto?,
teacher: Boolean,
): List<StudentRowDto> {
if (!teacher) return api.groupDetail(token, groupId).students
val effectiveCourseId =
courseId?.trim()?.takeIf { it.isNotBlank() }
?: throw ApiException(400, "Не указан курс")
api.courseDetail(token, effectiveCourseId)
return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student ->
if (course == null) throw ApiException(400, "Не указан курс")
return api.courseGroupStudents(token, course.id, groupId).students.map { student ->
StudentRowDto(
id = student.id,
studentNo = student.studentNo,
@@ -489,7 +519,7 @@ class AdminGroupController(
fun setGrade(
@PathVariable id: String,
@ModelAttribute form: SetGradeForm,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
@@ -509,10 +539,10 @@ class AdminGroupController(
),
)
redirect.addFlashAttribute("notice", "Оценка сохранена")
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
}
/** Уровни и варианты лабораторной для модалки назначения варианта. */
@@ -571,15 +601,16 @@ class AdminGroupController(
fun downloadGroupSubmissions(
@PathVariable id: String,
@RequestParam(required = false) labId: String?,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
): ResponseEntity<ByteArray> {
val t = token(session)
val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher)
val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val studentById = students.associateBy { it.id }
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) }
val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == course?.id) }
val groupName = if (teacher) groupNameFor(t, id, course) else api.groupDetail(t, id).name
val bytes = buildGroupArchive(t, groupName, studentById, labs)
return zipAttachment("solutions-$groupName.zip", bytes)
}
@@ -588,10 +619,10 @@ class AdminGroupController(
private fun groupNameFor(
token: String,
groupId: String,
courseId: String?,
course: CourseDetailDto?,
): String {
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName }
val effectiveCourse = course ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourse.id, groupId).groupName }
.getOrDefault("")
.ifBlank { groupId }
}
@@ -603,19 +634,20 @@ class AdminGroupController(
@PathVariable id: String,
@RequestParam labId: String,
@RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
val t = token(session)
val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
val course = resolveCourse(t, courseNo)
val all = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId)
groupRedirect(id, courseNo)
}
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
@@ -627,12 +659,13 @@ class AdminGroupController(
@RequestParam labId: String,
@RequestParam studentId: String,
@RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
): Map<String, Any> =
try {
val t = token(session)
val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id }
val course = resolveCourse(t, courseNo)
val memberIds = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
val memberIdSet = memberIds.toSet()
val state = api.publicationState(t, id, labId)
val current =

View File

@@ -26,6 +26,7 @@ import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.addLevel
import ru.ysu.lms.web.api.assignVariant
import ru.ysu.lms.web.api.copyLab
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.createLab
import ru.ysu.lms.web.api.deleteLab
import ru.ysu.lms.web.api.deleteLevel
@@ -55,15 +56,21 @@ class AdminLabController(
) : AdminSupport(api, markdown, mapper) {
@GetMapping("/labs")
fun labs(
@RequestParam(required = false) courseId: String?,
@RequestParam(required = false) courseNo: String?,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
model.addAttribute("labs", api.listLabs(t, selectedCourseId))
val selected =
courseNo?.trim()?.takeIf { it.isNotBlank() }?.let {
runCatching { api.courseByUrlNo(t, it) }
.onFailure { model.addAttribute("error", "Курс не найден") }
.getOrNull()
}
model.addAttribute("labs", api.listLabs(t, selected?.id))
model.addAttribute("courses", coursesFor(session))
model.addAttribute("selectedCourseId", selectedCourseId)
model.addAttribute("selectedCourseNo", selected?.courseNo)
model.addAttribute("selectedCourseId", selected?.id)
return "admin/labs"
}
@@ -151,7 +158,7 @@ class AdminLabController(
model.addAttribute("courses", coursesFor(session))
model.addAttribute(
"courseUrl",
lab.courseId?.let { cid -> if (user?.isAdmin == true) "/admin/courses/$cid" else "/teacher/courses/$cid" },
lab.courseNo?.let { no -> if (user?.isAdmin == true) "/admin/courses/$no" else "/teacher/courses/$no" },
)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute(

View File

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

View File

@@ -150,25 +150,25 @@ abstract class AdminSupport(
protected fun redirectToGroup(
groupId: String?,
labId: String?,
courseId: String? = null,
courseNo: String? = null,
): String =
when {
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseId)
!groupId.isNullOrBlank() -> groupRedirect(groupId, courseNo)
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
else -> "redirect:/admin/groups"
}
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */
/** Возврат на ведомость группы; преподаватель дополнительно сохраняет номер курса. */
protected fun groupRedirect(
groupId: String,
courseId: String?,
): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}"
courseNo: String?,
): String = "redirect:/admin/groups/$groupId${courseQuery(courseNo)}"
private fun courseQuery(courseId: String?): String =
courseId
private fun courseQuery(courseNo: String?): String =
courseNo
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
?.let { "?courseNo=" + URLEncoder.encode(it, StandardCharsets.UTF_8) }
.orEmpty()
protected fun buildGroupArchive(

View File

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

View File

@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
@@ -33,10 +35,25 @@ fun ApiClient.contentArticle(
.body(ContentArticleDto::class.java)
}
fun ApiClient.contentTreeList(token: String?): List<ContentArticleDto> {
fun ApiClient.contentTreeList(
token: String?,
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call {
val spec = client.get().uri("/api/v1/content/articles")
val spec =
client
.get()
.uri { builder ->
builder.path("/api/v1/content/articles")
if (limit > 0) {
builder
.queryParam("offset", offset.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve()
.body(type)
@@ -165,6 +182,7 @@ fun ApiClient.createChapter(
token: String,
title: String,
slug: String?,
audience: String? = null,
): Map<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
return call {
@@ -173,12 +191,29 @@ fun ApiClient.createChapter(
.uri("/api/v1/content/chapters")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "slug" to slug))
.body(mapOf("title" to title, "slug" to slug, "audience" to audience))
.retrieve()
.body(type)
}
}
fun ApiClient.updateChapterAudience(
token: String,
chapterId: String,
audience: String,
) {
call {
client
.put()
.uri("/api/v1/content/chapters/{id}/audience", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("audience" to audience))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.renameChapter(
token: String,
chapterId: String,

View File

@@ -57,6 +57,15 @@ fun ApiClient.courseDetail(
.body(CourseDetailDto::class.java)
}
/** Курс по номеру из браузерного URL; некорректный номер — 404, а не 500. */
fun ApiClient.courseByUrlNo(
token: String,
rawCourseNo: String,
): CourseDetailDto {
val courseNo = rawCourseNo.trim().toLongOrNull() ?: throw ApiException(404, "Курс не найден")
return courseByNo(token, courseNo)
}
/** Курс по человекочитаемому номеру из URL (`course_no`). */
fun ApiClient.courseByNo(
token: String,

View File

@@ -8,13 +8,24 @@ import org.springframework.util.LinkedMultiValueMap
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
fun ApiClient.listGroups(token: String): List<GroupDto> {
fun ApiClient.listGroups(
token: String,
page: Int = 0,
limit: Int = 0,
): List<GroupDto> {
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder.path("/api/v1/groups")
if (limit > 0) {
builder
.queryParam("page", page.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
@@ -39,6 +50,7 @@ fun ApiClient.importGroup(
token: String,
filename: String,
bytes: ByteArray,
restoreInactiveStatuses: Boolean = false,
): ImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add(
@@ -50,8 +62,12 @@ fun ApiClient.importGroup(
return call {
client
.post()
.uri("/api/v1/groups/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder
.path("/api/v1/groups/import")
.queryParam("restoreInactiveStatuses", restoreInactiveStatuses)
.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve()
@@ -102,21 +118,6 @@ fun ApiClient.enrollStudent(
}
}
fun ApiClient.unenrollStudent(
token: String,
groupId: String,
studentId: String,
) {
call {
client
.delete()
.uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.resetPassword(
token: String,
studentId: String,
@@ -175,6 +176,34 @@ fun ApiClient.archiveGroup(
.body(GroupDetailDto::class.java)
}
fun ApiClient.groupHistory(
token: String,
groupId: String,
): List<MembershipHistoryDto> {
val type = object : ParameterizedTypeReference<List<MembershipHistoryDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups/{id}/history", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.restoreGroup(
token: String,
groupId: String,
): GroupDetailDto =
call {
client
.post()
.uri("/api/v1/groups/{id}/restore", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(GroupDetailDto::class.java)
}
fun ApiClient.credentials(
token: String,
groupId: String,

View File

@@ -7,12 +7,17 @@ fun ApiClient.studentProfile(
token: String,
courseId: String,
studentId: String,
submissionLimit: Int = 50,
): StudentCourseProfileDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/students/{studentId}/profile", courseId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri(
"/api/v1/courses/{courseId}/students/{studentId}/profile?submissionLimit={limit}",
courseId,
studentId,
submissionLimit.coerceIn(1, 500),
).header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(StudentCourseProfileDto::class.java)
}

View File

@@ -19,6 +19,7 @@ data class AuthResponseDto(
val role: String = "",
val name: String = "",
val mustChangePassword: Boolean = false,
val readOnly: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -29,6 +30,7 @@ data class MeDto(
val name: String = "",
val mustChangePassword: Boolean = false,
val active: Boolean = true,
val readOnly: Boolean = false,
)
data class LoginPayload(
@@ -94,6 +96,7 @@ data class ImportResultDto(
val groupName: String? = null,
val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(),
val skipped: List<ImportErrorDto> = emptyList(),
val errors: List<ImportErrorDto> = emptyList(),
)
@@ -129,6 +132,22 @@ data class GroupDetailDto(
val students: List<StudentRowDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MembershipHistoryDto(
val id: String = "",
val studentId: String = "",
val studentName: String = "",
val fromGroupId: String? = null,
val fromGroupName: String? = null,
val toGroupId: String? = null,
val toGroupName: String? = null,
val action: String = "",
val reason: String = "",
val at: String = "",
) {
var atMsk: String? = null
}
data class StudentCandidateDto(
val id: String = "",
val fullName: String = "",
@@ -597,6 +616,7 @@ data class ContentChapterNodeDto(
val slug: String = "",
val position: Int? = 0,
val published: Boolean = true,
val audience: String? = null,
val sections: List<ContentSectionNodeDto> = emptyList(),
)
@@ -756,6 +776,7 @@ data class ProfileLabDto(
val grade: ProfileGradeDto? = null,
val comments: List<ProfileCommentDto> = emptyList(),
val defense: ProfileDefenseDto? = null,
val submissionsTotal: Int = 0,
val submissions: List<ProfileSubmissionDto> = emptyList(),
) {
/** Локальные для UI значения, заполняются контроллером. */
@@ -765,12 +786,15 @@ data class ProfileLabDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileVariantDto(
val id: String = "",
val number: String = "",
val title: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileLevelDto(
val id: String = "",
val code: String = "",
val title: String = "",
)
@@ -780,7 +804,7 @@ data class ProfileGradeDto(
val score: Any? = null,
val published: Boolean = false,
val generalComment: String = "",
val author: ProfileAuthorDto? = null,
val updatedBy: ProfileAuthorDto? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -821,7 +845,7 @@ data class ProfileSubmissionDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileAuthorDto(
val id: String = "",
val login: String = "",
val name: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.StudentCourseProfileDto
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.downloadCourseSubmission
import ru.ysu.lms.web.api.studentProfile
import ru.ysu.lms.web.session.SessionKeys
@@ -26,32 +27,34 @@ class StudentProfileController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
@GetMapping("/teacher/courses/{courseId}/students/{studentId}")
@GetMapping("/teacher/courses/{courseNo}/students/{studentId}")
fun teacherProfile(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
): String = renderProfile(courseNo, studentId, session, model)
@GetMapping("/admin/courses/{courseId}/students/{studentId}")
@GetMapping("/admin/courses/{courseNo}/students/{studentId}")
fun adminProfile(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable studentId: String,
session: HttpSession,
model: Model,
): String = renderProfile(courseId, studentId, session, model)
): String = renderProfile(courseNo, studentId, session, model)
private fun renderProfile(
courseId: String,
courseNo: String,
studentId: String,
session: HttpSession,
model: Model,
): String {
val profile = api.studentProfile(token(session), courseId, studentId)
val token = token(session)
val course = api.courseByUrlNo(token, courseNo)
val profile = api.studentProfile(token, course.id, studentId)
prepareProfile(profile)
val isAdmin = sessionUser(session)?.isAdmin == true
val courseUrl = if (isAdmin) "/admin/courses/$courseId" else "/teacher/courses/$courseId"
val courseUrl = if (isAdmin) "/admin/courses/${course.courseNo}" else "/teacher/courses/${course.courseNo}"
model.addAttribute("profile", profile)
model.addAttribute("isAdmin", isAdmin)
model.addAttribute("courseUrl", courseUrl)
@@ -61,37 +64,39 @@ class StudentProfileController(
model.addAttribute(
"downloadBase",
if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download"
"/admin/courses/{courseNo}/submissions/{id}/download"
} else {
"/teacher/courses/{courseId}/submissions/{id}/download"
"/teacher/courses/{courseNo}/submissions/{id}/download"
},
)
return "profile/student"
}
@GetMapping("/teacher/courses/{courseId}/submissions/{id}/download")
@GetMapping("/teacher/courses/{courseNo}/submissions/{id}/download")
fun teacherDownload(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
): ResponseEntity<ByteArray> = downloadSubmission(courseNo, id, form, session)
@GetMapping("/admin/courses/{courseId}/submissions/{id}/download")
@GetMapping("/admin/courses/{courseNo}/submissions/{id}/download")
fun adminDownload(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
): ResponseEntity<ByteArray> = downloadSubmission(courseNo, id, form, session)
private fun downloadSubmission(
courseId: String,
courseNo: String,
id: String,
form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> {
val file = api.downloadCourseSubmission(token(session), courseId, id)
val token = token(session)
val course = api.courseByUrlNo(token, courseNo)
val file = api.downloadCourseSubmission(token, course.id, id)
val filename = submissionFilename(form) ?: file.filename.ifBlank { "submission-$id.zip" }
val encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
return ResponseEntity

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseAvailableGroups
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.courseGroups
import ru.ysu.lms.web.api.detachCourseGroup
@@ -49,24 +49,27 @@ class TeacherController(
return "teacher/dashboard"
}
@GetMapping("/courses/{id}")
@GetMapping("/courses/{courseNo}")
fun course(
@PathVariable id: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val course = api.courseDetail(token, id)
val course = api.courseByUrlNo(token, courseNo)
val groups = course.groups.filter { it.removedAt == null }
// Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет id.
val attachedGroups = runCatching { api.courseGroups(token, id) }.getOrDefault(emptyList())
val attachedGroups = runCatching { api.courseGroups(token, course.id) }.getOrDefault(emptyList())
model.addAttribute("course", course)
model.addAttribute("groups", groups)
model.addAttribute("groupNames", attachedGroups.associate { it.id to it.name.ifBlank { it.id } })
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
model.addAttribute(
"availableGroups",
runCatching { api.courseAvailableGroups(token, course.id) }.getOrDefault(emptyList()),
)
model.addAttribute(
"labs",
api.listLabs(token, id).map { lab ->
api.listLabs(token, course.id).map { lab ->
LabRow(
id = lab.id,
number = lab.number,
@@ -81,53 +84,68 @@ class TeacherController(
return "teacher/course"
}
@PostMapping("/courses/{id}/groups/attach")
@PostMapping("/courses/{courseNo}/groups/attach")
fun attachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course =
try {
api.courseByUrlNo(token, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
return "redirect:/teacher"
}
return try {
val target = groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") }
api.attachCourseGroup(token, id, CourseGroupPayload(target))
api.attachCourseGroup(token, course.id, CourseGroupPayload(target))
redirect.addFlashAttribute("notice", "Группа привязана к курсу")
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
}
}
@PostMapping("/courses/{id}/groups/detach")
@PostMapping("/courses/{courseNo}/groups/detach")
fun detachGroup(
@PathVariable id: String,
@PathVariable courseNo: String,
@RequestParam groupId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course =
try {
api.courseByUrlNo(token, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
return "redirect:/teacher"
}
return try {
api.detachCourseGroup(token, id, groupId)
api.detachCourseGroup(token, course.id, groupId)
redirect.addFlashAttribute("notice", "Группа отвязана от курса")
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/teacher/courses/$id"
"redirect:/teacher/courses/${course.courseNo}"
}
}
@GetMapping("/courses/{courseId}/groups/{groupId}")
@GetMapping("/courses/{courseNo}/groups/{groupId}")
fun group(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable groupId: String,
session: HttpSession,
model: Model,
): String {
val token = token(session) ?: return "redirect:/login"
val roster = api.courseGroupStudents(token, courseId, groupId)
model.addAttribute("courseId", courseId)
val course = api.courseByUrlNo(token, courseNo)
val roster = api.courseGroupStudents(token, course.id, groupId)
model.addAttribute("courseNo", course.courseNo)
model.addAttribute("groupId", groupId)
model.addAttribute("roster", roster)
return "teacher/group"

View File

@@ -12,10 +12,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CreateArticlePayload
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseContentArticle
import ru.ysu.lms.web.api.courseContentTree
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.createCourseArticle
import ru.ysu.lms.web.api.createCourseChapter
import ru.ysu.lms.web.api.createCourseSection
@@ -33,22 +34,23 @@ import ru.ysu.lms.web.session.SessionKeys
* каждое действие — обычный POST с редиректом назад на страницу курса.
*/
@Controller
@RequestMapping("/teacher/courses/{courseId}/content")
@RequestMapping("/teacher/courses/{courseNo}/content")
class TeacherCourseContentController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
@GetMapping
fun content(
@PathVariable courseId: String,
@PathVariable courseNo: String,
session: HttpSession,
model: Model,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
model.addAttribute("course", api.courseDetail(token, courseId))
model.addAttribute("tree", api.courseContentTree(token, courseId))
model.addAttribute("course", course)
model.addAttribute("tree", api.courseContentTree(token, course.id))
// Рендерер отдаём в шаблон, чтобы предпросмотр статей всегда совпадал с тем,
// что увидит студент (та же санитизация markdown).
model.addAttribute("markdown", markdown)
@@ -61,64 +63,67 @@ class TeacherCourseContentController(
@PostMapping("/chapters")
fun createChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseChapter(token, courseId, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() })
api.createCourseChapter(token, course.id, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() })
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/chapters/{id}/rename")
fun renameChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.renameCourseChapter(token, courseId, id, title.trim())
api.renameCourseChapter(token, course.id, id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/chapters/{id}/delete")
fun deleteChapter(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseChapter(token, courseId, id)
api.deleteCourseChapter(token, course.id, id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/sections")
fun createSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam chapterId: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
@@ -126,64 +131,67 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseSection(
token,
courseId,
course.id,
chapterId,
title.trim(),
slug?.trim()?.takeIf { it.isNotBlank() },
)
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/sections/{id}/rename")
fun renameSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.renameCourseSection(token, courseId, id, title.trim())
api.renameCourseSection(token, course.id, id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@PostMapping("/sections/{id}/delete")
fun deleteSection(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam(required = false) moveTo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseSection(token, courseId, id, moveTo)
api.deleteCourseSection(token, course.id, id, moveTo)
redirect.addFlashAttribute("notice", "Раздел удалён")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/articles")
fun createArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@RequestParam sectionId: String,
@RequestParam title: String,
@RequestParam(defaultValue = "") bodyMd: String,
@@ -191,10 +199,11 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.createCourseArticle(
token,
courseId,
course.id,
CreateArticlePayload(
chapterId = null,
sectionId = sectionId,
@@ -205,17 +214,17 @@ class TeacherCourseContentController(
),
)
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
@@ -224,23 +233,24 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
val dto = api.courseContentArticle(token, courseId, chapter, section, article)
model.addAttribute("course", api.courseDetail(token, courseId))
val dto = api.courseContentArticle(token, course.id, chapter, section, article)
model.addAttribute("course", course)
model.addAttribute("article", dto)
model.addAttribute("bodyHtml", markdown.render(dto.bodyMd))
model.addAttribute("back", articlePath(courseId, chapter, section, article))
model.addAttribute("back", articlePath(course.courseNo, chapter, section, article))
"teacher/content-article"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectContent(courseId)
redirectContent(course.courseNo)
}
}
@Suppress("LongParameterList")
@PostMapping("/articles/{id}/save")
fun saveArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam title: String,
@RequestParam(defaultValue = "") bodyMd: String,
@@ -249,39 +259,41 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.updateCourseArticle(token, courseId, id, title.trim(), bodyMd)
api.updateCourseArticle(token, course.id, id, title.trim(), bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена")
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
@PostMapping("/articles/{id}/delete")
fun deleteArticle(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
return try {
api.deleteCourseArticle(token, courseId, id)
api.deleteCourseArticle(token, course.id, id)
redirect.addFlashAttribute("notice", "Статья удалена")
redirectContent(courseId)
redirectContent(course.courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
@Suppress("LongParameterList")
@PostMapping("/{kind}/{id}/published")
fun published(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable kind: String,
@PathVariable id: String,
@RequestParam published: Boolean,
@@ -290,33 +302,47 @@ class TeacherCourseContentController(
redirect: RedirectAttributes,
): String {
val token = token(session) ?: return "redirect:/login"
val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher"
if (kind !in setOf("chapters", "sections", "articles")) {
redirect.addFlashAttribute("error", "Неизвестный тип материала")
return redirectBack(back, contentPath(courseId))
return redirectBack(back, contentPath(course.courseNo))
}
return try {
api.setCourseContentPublished(token, courseId, kind, id, published)
api.setCourseContentPublished(token, course.id, kind, id, published)
redirect.addFlashAttribute(
"notice",
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, contentPath(courseId))
redirectBack(back, contentPath(course.courseNo))
}
}
private fun contentPath(courseId: String): String = "/teacher/courses/$courseId/content"
/** Курс по номеру из браузерного URL; ошибка резолва — сообщение и возврат к списку курсов. */
private fun resolveCourse(
token: String,
courseNo: String,
redirect: RedirectAttributes,
): CourseDetailDto? =
try {
api.courseByUrlNo(token, courseNo)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
null
}
private fun contentPath(courseNo: Long): String = "/teacher/courses/$courseNo/content"
private fun articlePath(
courseId: String,
courseNo: Long,
chapter: String,
section: String,
article: String,
): String = "${contentPath(courseId)}/$chapter/$section/$article"
): String = "${contentPath(courseNo)}/$chapter/$section/$article"
private fun redirectContent(courseId: String): String = "redirect:${contentPath(courseId)}"
private fun redirectContent(courseNo: Long): String = "redirect:${contentPath(courseNo)}"
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
private fun redirectBack(

View File

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

View File

@@ -11,7 +11,7 @@
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<form class="inline-form" th:action="@{/admin/courses/{id}/status(id=${course.id})}" method="post"
<form class="inline-form" th:action="@{/admin/courses/{courseNo}/status(courseNo=${course.courseNo})}" method="post"
th:attr="data-confirm=${course.status == 'ACTIVE' ? 'Переместить курс в архив? Привязка групп и преподавателей станет недоступна.' : 'Вернуть курс в работу?'}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="status" th:value="${course.status == 'ACTIVE' ? 'ARCHIVED' : 'ACTIVE'}">
@@ -37,13 +37,13 @@
<tbody>
<tr th:each="attached : ${attachedGroups}">
<td>
<a th:href="@{/admin/groups/{id}(id=${attached.groupId}, courseId=${course.id})}"
<a th:href="@{/admin/groups/{id}(id=${attached.groupId}, courseNo=${course.courseNo})}"
th:text="${groupNames[attached.groupId] ?: attached.groupId}">—</a>
</td>
<td class="text-muted" th:text="${attached.addedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/groups/{groupId}/detach(id=${course.id}, groupId=${attached.groupId})}"
th:action="@{/admin/courses/{courseNo}/groups/{groupId}/detach(courseNo=${course.courseNo}, groupId=${attached.groupId})}"
method="post" data-confirm="Отвязать группу от курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отвязать</button>
@@ -57,7 +57,7 @@
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/groups(id=${course.id})}"
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{courseNo}/groups(courseNo=${course.courseNo})}"
method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
@@ -89,7 +89,7 @@
<td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/teachers/{accountId}/remove(id=${course.id}, accountId=${teacher.accountId})}"
th:action="@{/admin/courses/{courseNo}/teachers/{accountId}/remove(courseNo=${course.courseNo}, accountId=${teacher.accountId})}"
method="post" data-confirm="Снять преподавателя с курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Снять</button>
@@ -103,7 +103,7 @@
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/teachers(id=${course.id})}"
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{courseNo}/teachers(courseNo=${course.courseNo})}"
method="post">
<div><label class="label" for="teacherAccount">Преподаватель</label>
<select class="input" id="teacherAccount" name="accountId" required
@@ -133,7 +133,7 @@
<div class="flex flex-wrap items-center justify-between gap-2 mt-4 mb-2">
<h2 class="section-title m-0">Лабораторные курса</h2>
<a class="text-sm" th:href="@{/admin/labs(courseId=${course.id})}">Открыть в списке лабораторных →</a>
<a class="text-sm" th:href="@{/admin/labs(courseNo=${course.courseNo})}">Открыть в списке лабораторных →</a>
</div>
<div class="card card-flush">
<table>

View File

@@ -45,7 +45,7 @@
<tbody>
<tr th:each="course : ${courses}">
<td th:text="${course.courseNo}">1</td>
<td><a th:href="@{/admin/courses/{id}(id=${course.id})}" th:text="${course.title}">—</a></td>
<td><a th:href="@{/admin/courses/{courseNo}(courseNo=${course.courseNo})}" th:text="${course.title}">—</a></td>
<td th:attr="data-filter-value=${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
@@ -55,7 +55,7 @@
<td th:text="${course.groupCount}">0</td>
<td th:text="${course.labCount}">0</td>
<td class="text-center">
<a class="btn btn-sm btn-secondary" th:href="@{/admin/courses/{id}(id=${course.id})}">Открыть</a>
<a class="btn btn-sm btn-secondary" th:href="@{/admin/courses/{courseNo}(courseNo=${course.courseNo})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(courses)}"><td colspan="7" class="empty">Курсов пока нет.</td></tr>

View File

@@ -32,6 +32,11 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">В архив</button>
</form>
<form class="inline-form" th:if="${group.status == 'ARCHIVED'}"
th:action="@{/admin/groups/{id}/restore(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn" type="submit">Вернуть из архива</button>
</form>
</div>
</div>
@@ -46,7 +51,7 @@
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url"
th:value="${profileCourseId != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
th:value="${profileCourseNo != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x">
@@ -60,14 +65,14 @@
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<button class="btn btn-secondary btn-sm p-2" type="button"
th:attr="data-url=${profileCourseId != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
th:attr="data-url=${profileCourseNo != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'">
@@ -81,7 +86,7 @@
</button>
</form>
<a class="btn btn-secondary btn-sm p-2"
th:href="${profileCourseId != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:href="${profileCourseNo != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
</div>
</div>
@@ -124,10 +129,10 @@
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<a class="student-name" th:if="${profileCourseId != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}"
<a class="student-name" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}"
th:text="${row.fullName}">—</a>
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div>
<div class="student-name" th:if="${profileCourseNo == null}" th:text="${row.fullName}">—</div>
<div class="student-no-line">
<span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
@@ -196,8 +201,8 @@
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a>
<a class="btn btn-secondary btn-sm" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}">Профиль</a>
<form th:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(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"
@@ -240,15 +245,6 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
</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>
</td>
</tr>
@@ -267,7 +263,7 @@
<div class="dlg-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
@@ -342,7 +338,7 @@
<form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-random-student">
<input type="hidden" name="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level">
@@ -351,7 +347,7 @@
<form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-pin-student">
<input type="hidden" name="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id">
@@ -369,7 +365,7 @@
<div class="dlg-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="defense-student-select">Студент</label>
<select class="input" id="defense-student-select" name="studentId" required>
@@ -390,14 +386,14 @@
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
<form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}">
<input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button>
@@ -411,6 +407,36 @@
<div class="card card-flush mt-6" th:if="${!teacherMode and !#lists.isEmpty(history)}">
<div class="card-body">
<h2 class="section-title m-0">История членства</h2>
<p class="text-sm text-muted mt-1 mb-3">Последние события по группе: зачисление, перевод, импорт, академ/возврат, повышение и архив.</p>
<div class="scroll-x">
<table class="tablekit">
<thead>
<tr><th>Когда (МСК)</th><th>Студент</th><th>Событие</th><th>Откуда → куда</th><th>Причина</th></tr>
</thead>
<tbody>
<tr th:each="event : ${history}">
<td class="text-sm" th:text="${event.atMsk ?: event.at}">—</td>
<td th:text="${event.studentName} ?: event.studentId">—</td>
<td>
<span class="badge"
th:text="${event.action == 'ENROLL' ? 'зачислен' : (event.action == 'TRANSFER' ? 'переведён' : (event.action == 'IMPORT' ? 'импорт' : (event.action == 'ACADEMIC' ? 'академ. отпуск' : (event.action == 'RETURN' ? 'возврат' : (event.action == 'GRADUATE' ? 'выпущен' : (event.action == 'EXPELL' ? 'отчислен' : (event.action == 'PROMOTE' ? 'повышен' : (event.action == 'ARCHIVE' ? 'архив' : (event.action == 'RESTORE' ? 'восстановлен' : event.action)))))))))}">—</span>
</td>
<td class="text-sm">
<span th:text="${event.fromGroupName} ?: '—'">—</span>
<span class="text-muted"> → </span>
<span th:text="${event.toGroupName} ?: '—'">—</span>
</td>
<td class="text-sm text-muted" th:text="${event.reason} ?: '—'">—</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="dlg" th:if="${!teacherMode}">
<div class="dlg-header">
@@ -487,6 +513,23 @@
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</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>
</div>
</div>

View File

@@ -56,6 +56,15 @@
</table>
</div>
<div class="flex items-center justify-between gap-4 mt-4"
th:if="${(page ?: 0) > 0 or (hasNext ?: false)}">
<a class="btn btn-secondary" th:if="${(page ?: 0) > 0}"
th:href="@{/admin/groups(page=${(page ?: 0) - 1})}">← Назад</a>
<span class="text-sm text-muted">Страница <b th:text="${(page ?: 0) + 1}">1</b></span>
<a class="btn btn-secondary" th:if="${hasNext ?: false}"
th:href="@{/admin/groups(page=${(page ?: 0) + 1})}">Далее →</a>
</div>
<dialog id="modal-create-group" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая группа</h2>
@@ -101,6 +110,16 @@
</p>
<div><label class="label" for="file">Файл со списком</label>
<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>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>

View File

@@ -7,10 +7,10 @@
<h1 class="page-title">Лабораторные</h1>
<div class="flex flex-wrap gap-2 items-center">
<form method="get" th:action="@{/admin/labs}" class="m-0">
<select class="input" name="courseId" aria-label="Фильтр по курсу" onchange="this.form.submit()">
<option value="" th:selected="${selectedCourseId == null}">Все курсы</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:selected="${course.id == selectedCourseId}"
<select class="input" name="courseNo" aria-label="Фильтр по курсу" onchange="this.form.submit()">
<option value="" th:selected="${selectedCourseNo == null}">Все курсы</option>
<option th:each="course : ${courses}" th:value="${course.courseNo}"
th:selected="${course.courseNo == selectedCourseNo}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<noscript><button class="btn btn-secondary" type="submit">Фильтр</button></noscript>
@@ -37,7 +37,7 @@
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td>
<a th:if="${lab.courseId != null}" th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
<a th:if="${lab.courseId != null}" th:href="@{/admin/courses/{courseNo}(courseNo=${lab.courseNo})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a>
<span th:if="${lab.courseId == null}" class="text-muted">—</span>
</td>

View File

@@ -32,6 +32,17 @@
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}">
@@ -206,6 +217,17 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="chTitle">Название</label>
<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>
</form>
</div>

View File

@@ -129,8 +129,8 @@
<h4 class="group-title">Оценка</h4>
<p class="mb-2" th:if="${lab.grade != null}">
<span class="score-big" th:text="${lab.grade.score}">4.5</span>
<span class="text-sm text-muted" th:if="${lab.grade.author != null}"
th:text="|Автор: ${lab.grade.author.login}|"></span>
<span class="text-sm text-muted" th:if="${lab.grade.updatedBy != null}"
th:text="|Автор: ${lab.grade.updatedBy.name}|"></span>
</p>
<p class="text-muted text-sm" th:if="${lab.grade == null}">Оценка ещё не выставлена.</p>
<div class="general-comment" th:if="${lab.grade != null and !#strings.isEmpty(lab.grade.generalComment)}">
@@ -169,7 +169,8 @@
th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный</span>
<span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span>
th:text="|— ${comment.author.name}|"></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}"
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
</li>
@@ -177,6 +178,9 @@
<h4 class="group-title mt-4">Попытки сдачи</h4>
<p class="text-muted text-sm mb-0" th:if="${#lists.isEmpty(lab.submissions)}">Сдач по этой лабораторной нет.</p>
<p class="text-muted text-sm mb-2"
th:if="${lab.submissionsTotal > #lists.size(lab.submissions)}"
th:text="|Показаны последние ${#lists.size(lab.submissions)} из ${lab.submissionsTotal} попыток.|">Показаны последние попытки.</p>
<div class="card card-flush" th:if="${!#lists.isEmpty(lab.submissions)}">
<table>
<thead>
@@ -193,7 +197,7 @@
</td>
<td class="text-right">
<a class="btn btn-secondary btn-sm"
th:href="@{${downloadBase}(courseId=${profile.course.id}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
th:href="@{${downloadBase}(courseNo=${profile.course.courseNo}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
</td>
</tr>
</tbody>

View File

@@ -27,7 +27,8 @@
<h2 class="section-title">Сдача работы</h2>
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
@@ -87,7 +88,7 @@
<div th:if="${defense == null}" class="text-muted">
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
</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>
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<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>
<h3 class="group-title">Ваше индивидуальное задание</h3>
<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}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>

View File

@@ -4,9 +4,9 @@
<body>
<div id="content">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}" class="nav-link" th:text="${course.title}">Курс</a>
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}" class="nav-link" th:text="${course.title}">Курс</a>
<span class="nav-sep">/</span>
<a th:href="@{/teacher/courses/{courseId}/content(courseId=${course.id})}"
<a th:href="@{/teacher/courses/{no}/content(no=${course.courseNo})}"
class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<span th:text="${article.chapterTitle}">Глава</span>
@@ -38,7 +38,7 @@
Студенческий вид
</a>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/published(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/published(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!article.published}">
@@ -47,7 +47,7 @@
th:text="${article.published ? 'Скрыть от студентов' : 'Опубликовать'}">Опубликовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/delete(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/delete(no=${course.courseNo}, id=${article.id})}"
method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
@@ -58,7 +58,7 @@
<div class="row g-4">
<div class="col-12 col-lg-7">
<form class="card card-body form-stack"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/save(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/save(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">

View File

@@ -3,7 +3,7 @@
<head><title th:text="${course.title} + ' — материалы — LMS'">Материалы курса</title></head>
<body>
<div id="content">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}" class="nav-link">← К курсу</a>
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}" class="nav-link">← К курсу</a>
<div class="page-head mb-2">
<div>
@@ -21,7 +21,7 @@
</div>
<form class="card card-body form-stack mt-4"
th:action="@{/teacher/courses/{courseId}/content/chapters(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/chapters(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<h2 class="section-title m-0">Новая глава</h2>
<div>
@@ -51,17 +51,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/published(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/published(no=${course.courseNo}, id=${chapter.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${chapter.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/rename(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/rename(no=${course.courseNo}, id=${chapter.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" name="title" th:value="${chapter.title}" required
@@ -69,7 +69,7 @@
<button class="btn btn-sm btn-secondary" type="submit">Переименовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/chapters/{id}/delete(courseId=${course.id}, id=${chapter.id})}"
th:action="@{/teacher/courses/{no}/content/chapters/{id}/delete(no=${course.courseNo}, id=${chapter.id})}"
method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -91,17 +91,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/published(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/published(no=${course.courseNo}, id=${section.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${section.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/rename(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/rename(no=${course.courseNo}, id=${section.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" name="title" th:value="${section.title}" required
@@ -109,7 +109,7 @@
<button class="btn btn-sm btn-secondary" type="submit">Переименовать</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/sections/{id}/delete(courseId=${course.id}, id=${section.id})}"
th:action="@{/teacher/courses/{no}/content/sections/{id}/delete(no=${course.courseNo}, id=${section.id})}"
method="post" data-confirm="Удалить раздел со всеми статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -120,28 +120,28 @@
<ul class="sort-list text-sm mt-2">
<li th:each="article : ${section.articles}">
<a class="sort-title"
th:href="@{/teacher/courses/{courseId}/content/{chapter}/{section}/{article}(courseId=${course.id}, chapter=${chapter.slug}, section=${section.slug}, article=${article.slug})}"
th:href="@{/teacher/courses/{no}/content/{chapter}/{section}/{article}(no=${course.courseNo}, chapter=${chapter.slug}, section=${section.slug}, article=${article.slug})}"
th:text="${article.title}">Статья</a>
<span class="badge"
th:classappend="${article.published ? 'badge-ok' : 'badge-warn'}"
th:text="${article.published ? 'опубликовано' : 'скрыто'}">—</span>
<div class="sort-actions">
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/published(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/published(no=${course.courseNo}, id=${article.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-secondary" type="submit"
th:text="${article.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</form>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/content/articles/{id}/delete(courseId=${course.id}, id=${article.id})}"
th:action="@{/teacher/courses/{no}/content/articles/{id}/delete(no=${course.courseNo}, id=${article.id})}"
method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back"
th:value="@{/teacher/courses/{courseId}/content(courseId=${course.id})}">
th:value="@{/teacher/courses/{no}/content(no=${course.courseNo})}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
</form>
</div>
@@ -150,7 +150,7 @@
</ul>
<form class="card card-body form-stack mt-2"
th:action="@{/teacher/courses/{courseId}/content/articles(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/articles(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="sectionId" th:value="${section.id}">
<div>
@@ -167,7 +167,7 @@
</div>
<form class="card card-body form-stack mt-4"
th:action="@{/teacher/courses/{courseId}/content/sections(courseId=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/content/sections(no=${course.courseNo})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="chapterId" th:value="${chapter.id}">
<div>

View File

@@ -16,7 +16,7 @@
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" th:href="@{/teacher/courses/{id}/content(id=${course.id})}">Материалы курса</a>
<a class="btn btn-sm btn-primary" th:href="@{/teacher/courses/{no}/content(no=${course.courseNo})}">Материалы курса</a>
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
</div>
</div>
@@ -42,18 +42,18 @@
class="font-mono"
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
<a th:unless="${currentUser.admin}"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}"
th:href="@{/teacher/courses/{no}/groups/{groupId}(no=${course.courseNo}, groupId=${group.groupId})}"
th:text="${groupNames[group.groupId] ?: group.groupId}">—</a>
</td>
<td class="text-muted" th:text="${group.addedAt}">—</td>
<td th:unless="${currentUser.admin}" class="text-center">
<div class="flex flex-wrap items-center justify-center gap-2">
<a class="btn btn-sm btn-secondary"
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseId=${course.id})}">Ведомость</a>
th:href="@{/admin/groups/{groupId}(groupId=${group.groupId}, courseNo=${course.courseNo})}">Ведомость</a>
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/groups/{groupId}(courseId=${course.id}, groupId=${group.groupId})}">Студенты</a>
th:href="@{/teacher/courses/{no}/groups/{groupId}(no=${course.courseNo}, groupId=${group.groupId})}">Студенты</a>
<form class="inline-form"
th:action="@{/teacher/courses/{courseId}/groups/detach(courseId=${course.id})}"
th:action="@{/teacher/courses/{no}/groups/detach(no=${course.courseNo})}"
method="post" data-confirm="Отвязать группу от курса? Ведомость и данные группы останутся в системе.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupId" th:value="${group.groupId}">
@@ -70,7 +70,7 @@
</div>
<form class="card card-body form-stack mt-4" th:unless="${currentUser.admin}"
th:action="@{/teacher/courses/{id}/groups/attach(id=${course.id})}" method="post">
th:action="@{/teacher/courses/{no}/groups/attach(no=${course.courseNo})}" method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
th:disabled="${#lists.isEmpty(availableGroups)}">

View File

@@ -11,7 +11,7 @@
<div class="card h-100">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}"
<a th:href="@{/teacher/courses/{no}(no=${course.courseNo})}"
th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
@@ -23,7 +23,7 @@
</div>
<div class="mt-4">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{id}(id=${course.id})}">Открыть курс</a>
th:href="@{/teacher/courses/{no}(no=${course.courseNo})}">Открыть курс</a>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More