Compare commits
10 Commits
f3d614bb33
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
| a42bc64193 | |||
| 82394cc688 | |||
| 7ef5c866a3 | |||
| 65ed08f5c2 | |||
| b57a5ffbce | |||
| 1b98b1828d | |||
| 1570196cc7 | |||
| 238c689175 | |||
| 9412d87fed | |||
| 9cb4d0d263 |
@@ -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 * * *'
|
||||
|
||||
10
README.md
10
README.md
@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
|
||||
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
|
||||
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
|
||||
Baseline: **51 claim, 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` с фильтром по действию,
|
||||
|
||||
@@ -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`); преподаватель и
|
||||
|
||||
@@ -103,7 +103,7 @@ assets/ # картинки для md
|
||||
|
||||
Материалы видны всем; глобальные материалы редактирует только администратор
|
||||
(«Материалы»). Преподаватель редактирует материалы своего курса на
|
||||
`/teacher/courses/{id}/content`.
|
||||
`/teacher/courses/{courseNo}/content`.
|
||||
|
||||
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
|
||||
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и
|
||||
|
||||
@@ -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:-}"
|
||||
;;
|
||||
|
||||
@@ -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 ">> Запуск сервисов..."
|
||||
|
||||
@@ -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` обязателен для тегов.
|
||||
|
||||
@@ -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-запросов в интеграционном тесте).
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 12. Отчёт этапа 2: academic (курсы и лабораторные)
|
||||
|
||||
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
|
||||
|
||||
**Дата:** 2026-10-27.
|
||||
**Статус:** этап 2 выполнен; этап 3 (доступ и профиль студента курса) — следующий.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 13. Отчёт этапа 3: доступ и профиль студента курса
|
||||
|
||||
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
|
||||
|
||||
**Дата:** 2026-10-27.
|
||||
**Статус:** этап 3 выполнен; этап 4 (повышение и жизненный цикл) — следующий.
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 с) зелёные.
|
||||
|
||||
@@ -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
185
docs/18-goal-audit.md
Normal 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`.
|
||||
@@ -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, миграции, доступ, статусы, повышение |
|
||||
|
||||
## Кратко о системе
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 зелёные. Это достаточное основание продолжать доработки, но не
|
||||
«формальное доказательство всей системы».
|
||||
|
||||
|
||||
44
docs/evidence/baseline-2026-10-29.md
Normal file
44
docs/evidence/baseline-2026-10-29.md
Normal 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`.
|
||||
@@ -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 событий; фильтра/пагинации по истории нет."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.hamcrest.Matchers.containsInAnyOrder
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.context.WebApplicationContext
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||
import ru.ysu.lms.academic.course.CourseService
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Аудитория глобальных глав: аноним видит только ALL, студент — ALL/STUDENTS,
|
||||
* преподаватель — ALL/TEACHERS, админ — всё. Курсовые главы аудитории не имеют.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
class GlobalAudienceAccessIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
@ServiceConnection
|
||||
@JvmStatic
|
||||
val postgres = PostgreSQLContainer("postgres:18")
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun assetsDir(registry: DynamicPropertyRegistry) {
|
||||
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private lateinit var context: WebApplicationContext
|
||||
|
||||
@Autowired
|
||||
private lateinit var authoring: ContentAuthoringService
|
||||
|
||||
@Autowired
|
||||
private lateinit var courseService: CourseService
|
||||
|
||||
@Autowired
|
||||
private lateinit var chapters: ContentChapterRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var sections: ContentSectionRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var articles: ContentArticleRepository
|
||||
|
||||
private val mockMvc: MockMvc by lazy {
|
||||
MockMvcBuilders
|
||||
.webAppContextSetup(context)
|
||||
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global tree is filtered by role audience`() {
|
||||
val all = seedMaterial(AUDIENCE_ALL)
|
||||
val students = seedMaterial(AUDIENCE_STUDENTS)
|
||||
val teachers = seedMaterial(AUDIENCE_TEACHERS)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/content/tree"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug)))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/content/tree").with(jwtFor("STUDENT")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(
|
||||
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, students.chapter.slug)),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/content/tree").with(jwtFor("TEACHER")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(
|
||||
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, teachers.chapter.slug)),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/content/tree/all").with(jwtFor("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(
|
||||
jsonPath(
|
||||
"$.chapters[*].slug",
|
||||
containsInAnyOrder(all.chapter.slug, students.chapter.slug, teachers.chapter.slug),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global article access follows audience and role`() {
|
||||
val students = seedMaterial(AUDIENCE_STUDENTS)
|
||||
val teachers = seedMaterial(AUDIENCE_TEACHERS)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(students)))
|
||||
.andExpect(status().isNotFound)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(students)).with(jwtFor("STUDENT")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value(students.article.title))
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(students)).with(jwtFor("TEACHER")))
|
||||
.andExpect(status().isNotFound)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(teachers)).with(jwtFor("TEACHER")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value(teachers.article.title))
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(teachers)).with(jwtFor("STUDENT")))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoring creates requested audience and rejects invalid`() {
|
||||
val node = authoring.createChapter("Студенческая глава ${UUID.randomUUID()}", null, audience = AUDIENCE_STUDENTS)
|
||||
assertThat(chapters.findById(UUID.fromString(node.id)).orElseThrow().audience).isEqualTo(AUDIENCE_STUDENTS)
|
||||
|
||||
val defaultNode = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", null)
|
||||
assertThat(chapters.findById(UUID.fromString(defaultNode.id)).orElseThrow().audience).isEqualTo(AUDIENCE_ALL)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/content/chapters")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"title":"Неверная аудитория","audience":"EVERYONE"}""")
|
||||
.with(jwtFor("ADMIN")),
|
||||
).andExpect(status().isBadRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `course chapter never gets audience and cannot change it`() {
|
||||
val courseId =
|
||||
UUID.fromString(
|
||||
courseService
|
||||
.create(CourseCreateRequest("Курс аудитории ${UUID.randomUUID()}"), UUID.randomUUID())
|
||||
.id,
|
||||
)
|
||||
val node =
|
||||
authoring.createChapter(
|
||||
"Курсовая глава ${UUID.randomUUID()}",
|
||||
null,
|
||||
courseId,
|
||||
audience = AUDIENCE_STUDENTS,
|
||||
)
|
||||
val chapterId = UUID.fromString(node.id)
|
||||
|
||||
assertThat(chapters.findById(chapterId).orElseThrow().audience).isNull()
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/content/chapters/{id}/audience", chapterId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"audience":"TEACHERS"}""")
|
||||
.with(jwtFor("ADMIN")),
|
||||
).andExpect(status().isBadRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing audience immediately changes access`() {
|
||||
val material = seedMaterial(AUDIENCE_STUDENTS)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
|
||||
.andExpect(status().isNotFound)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/content/chapters/{id}/audience", material.chapter.id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"audience":"TEACHERS"}""")
|
||||
.with(jwtFor("ADMIN")),
|
||||
).andExpect(status().isOk)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
mockMvc
|
||||
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
private fun seedMaterial(audience: String): SeededMaterial {
|
||||
val chapter =
|
||||
chapters.saveAndFlush(
|
||||
ContentChapter(
|
||||
title = "Глава ${UUID.randomUUID()}",
|
||||
slug = "chapter-${UUID.randomUUID()}",
|
||||
position = 1,
|
||||
published = true,
|
||||
audience = audience,
|
||||
),
|
||||
)
|
||||
val section =
|
||||
sections.saveAndFlush(
|
||||
ContentSection(
|
||||
chapterId = chapter.id,
|
||||
title = "Раздел ${UUID.randomUUID()}",
|
||||
slug = "section-${UUID.randomUUID()}",
|
||||
position = 1,
|
||||
published = true,
|
||||
),
|
||||
)
|
||||
val article =
|
||||
articles.saveAndFlush(
|
||||
ContentArticle(
|
||||
sectionId = section.id,
|
||||
title = "Статья ${UUID.randomUUID()}",
|
||||
slug = "article-${UUID.randomUUID()}",
|
||||
bodyMd = "Текст",
|
||||
position = 1,
|
||||
published = true,
|
||||
),
|
||||
)
|
||||
return SeededMaterial(chapter, section, article)
|
||||
}
|
||||
|
||||
private fun articlePath(material: SeededMaterial): String =
|
||||
"/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"
|
||||
|
||||
private fun jwtFor(role: String): RequestPostProcessor =
|
||||
jwt()
|
||||
.authorities(SimpleGrantedAuthority("ROLE_$role"))
|
||||
.jwt { it.subject(UUID.randomUUID().toString()).claim("role", role) }
|
||||
|
||||
private data class SeededMaterial(
|
||||
val chapter: ContentChapter,
|
||||
val section: ContentSection,
|
||||
val article: ContentArticle,
|
||||
)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ class CourseAccessServiceMutationTest {
|
||||
|
||||
@Test
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,8 @@ data class AccountInfoDto(
|
||||
val id: String,
|
||||
val login: String,
|
||||
val role: String,
|
||||
/** ФИО студента; для не-студенческих аккаунтов пусто. */
|
||||
val fullName: String = "",
|
||||
)
|
||||
|
||||
/** Группа с текущим составом — без учётных данных. */
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("Защита ещё не начата")
|
||||
|
||||
@@ -122,6 +122,7 @@ data class AccountInfo(
|
||||
val id: String = "",
|
||||
val login: String = "",
|
||||
val role: String = "",
|
||||
val fullName: String = "",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import java.util.UUID
|
||||
|
||||
/** Академический отпуск: чтение разрешено, сдача и защиты — нет. */
|
||||
@Component
|
||||
class StudentWriteGuard(
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
fun ensureWritable(studentId: UUID) {
|
||||
val status = identity.getStudentContext(studentId.toString()).status
|
||||
if (status == STATUS_ACADEMIC_LEAVE) {
|
||||
throw ForbiddenException("Академический отпуск: доступ только для чтения")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STATUS_ACADEMIC_LEAVE = "ACADEMIC_LEAVE"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class SubmissionService(
|
||||
private val submissions: SubmissionRepository,
|
||||
private val 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")
|
||||
|
||||
|
||||
@@ -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 на горячем пути и не засорять журнал.
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** Массовая установка уровня выделенным студентам. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -150,6 +150,7 @@ class AuthPageController(
|
||||
role = auth.role,
|
||||
name = auth.name,
|
||||
mustChangePassword = auth.mustChangePassword,
|
||||
readOnly = auth.readOnly,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
9
web/src/main/resources/static/robots.txt
Normal file
9
web/src/main/resources/static/robots.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /learn/
|
||||
Disallow: /learn/course/
|
||||
Disallow: /admin/
|
||||
Disallow: /teacher/
|
||||
Disallow: /student/
|
||||
Disallow: /change-password
|
||||
Disallow: /api/
|
||||
Disallow: /login
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user