feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости

- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
2026-10-29 20:51:27 +03:00
parent 7ef5c866a3
commit 82394cc688
48 changed files with 1196 additions and 156 deletions

View File

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **59 claims, 385 тестов, 18/18 browser E2E**. Baseline: **66 claims, 398 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)

View File

@@ -130,10 +130,24 @@ git pull
`deploy.sh up` **обязательно** делает backup перед обновлением. Если backup `deploy.sh up` **обязательно** делает backup перед обновлением. Если backup
завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются
автоматически при старте сервисов: identity `V4–V13` (`V10` создаёт автоматически при старте сервисов: identity `V4–V14` (`V10` создаёт
`identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` — `identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` —
`absolute_expires_at`, `V13` — keyset-индексы аудита), academic `V6–V12`, `absolute_expires_at`, `V13` — keyset-индексы аудита, `V14` — финальные
progress `V13`. 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` и видит события После обновления проверьте, что админ открывает `/admin/audit` и видит события
(например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и (например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и

View File

@@ -211,11 +211,11 @@
Номера уточняются при реализации, чтобы не конфликтовать с максимумами master Номера уточняются при реализации, чтобы не конфликтовать с максимумами master
(identity V3, **academic V5**, progress V12). Максимумы этой ветки: (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 | | 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` | | 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 — выполнен.** Права преподавателя на запись (лабораторные, - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events` ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**385**, 0 failures), upgrade миграций с master-схемы тестов/линтеров (**398**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица, (identity `V4–V14`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **59 claims gateway, видимость активов и deploy-runbook. LDD baseline: **66 claims
(27 P0 / 32 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: (27 P0 / 39 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md). [16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6 ### 9.1. Известные пробелы после этапа 6
- **Импорт.** Старый формат не поддерживается, принимается только шаблон v2. - **Импорт.** Старый формат не поддерживается, принимается только шаблон v2.
Номер группы — числовой; отдельного требования к ведущим нулям нет. Номер группы — числовой; отдельного требования к ведущим нулям нет.
CSV/XLSX preflight покрыт тестами; гонка с параллельной вставкой логина
всё ещё может дать общий 409.
- **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)` - **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)`
с offset-fallback для старых ссылок; `GET /api/v1/labs/details` отдаёт все с offset-fallback; `GET /api/v1/labs/details` отдаёт все лабораторные курса
лабораторные курса с уровнями и вариантами одним запросом; группы курса — одним запросом; группы курса — одним вызовом. Сдачи ведомости собираются
одним вызовом. Ведомость по-прежнему запрашивает сдачи по выбранной одним запросом на все выбранные лабы; группы и плоский список материалов
лабораторной — это не N+1 по студентам. поддерживают `limit`/`offset`. Страница группы и профиль имеют собственные
лимиты; дерево материалов по-прежнему загружается целиком для навигации.
- **Legacy-активы.** Файлы без статьи уже не публичны; админская страница - **Legacy-активы.** Файлы без статьи уже не публичны; админская страница
`/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся
прогнать инвентаризацию на production/staging и привязать или удалить файлы. прогнать инвентаризацию на production/staging и привязать или удалить файлы.
- **Визуальная регрессия.** Playwright покрывает сценарии и mobile, но
screenshot-diff отсутствует.
- **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней - **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная (`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная
задача (`LMS_REFRESH_TOKEN_RETENTION`). задача (`LMS_REFRESH_TOKEN_RETENTION`).
@@ -378,7 +383,7 @@ identity V13, **academic V12**, progress V13.
- Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней / - Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней /
7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и 7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и
закрытие доступа отзывают refresh. закрытие доступа отзывают refresh.
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V13`). - Security-матрица и миграционный upgrade-тест зелёные (identity до `V14`).
- CI (Gitea + Woodpecker) на каждом push/MR прогоняет - CI (Gitea + Woodpecker) на каждом push/MR прогоняет
`test ktlintCheck detekt evidenceCheck` и smoke полного стека; полный `test ktlintCheck detekt evidenceCheck` и smoke полного стека; полный
`release` обязателен для тегов. `release` обязателен для тегов.

View File

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

View File

@@ -73,7 +73,7 @@
| markdown | 5 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **385** | **0** | **0** | **0** | | **Итого на момент этапа 6** | **385** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **385 тестов, 0 failures** На момент этапа 6 LDD-baseline — **385 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация (см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже. gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки ### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 385 тестов, 0 failures) Проверено автоматическими тестами (прогон этапа 6 — 385 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс». negative-тестами «свой/чужой курс».
@@ -347,7 +347,7 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline ## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**59 claims, 27 P0 / 32 P1**), LDD-контур на момент этапа 6: реестр `docs/evidence/claims.json` (**59 claims, 27 P0 / 32 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow, gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат: mutation testing и браузерный Playwright E2E. Результат:
**59/59 claims proven, P0 gaps = 0, 385 тестов, 0 failures**; mutation strength **59/59 claims proven, P0 gaps = 0, 385 тестов, 0 failures**; mutation strength

View File

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

View File

@@ -13,20 +13,20 @@
| Область | Статус | Что осталось | | Область | Статус | Что осталось |
|---|---|---| |---|---|---|
| Роли, группы-когорты, статусы, история | partial | Финальные `NOT NULL`/полная уникальность когорты; группа не восстанавливается из архива; история членства не показана в UI | | Роли, группы-когорты, статусы, история | implemented | Финальные ограничения когорты, возврат из архива и блок истории членства на странице группы |
| Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service | | Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service |
| Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID | | Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID |
| «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив | | «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив |
| Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями | | Импорт v2 | partial | Флаг восстановления и row-level отчёт для CSV/XLSX есть; гонка с параллельной вставкой логина всё ещё даёт общий 409 |
| Курсы, преподаватели, группы курса | implemented | — | | Курсы, преподаватели, группы курса | implemented | — |
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` | | Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
| Профиль студента | partial | URL уже `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» | | Профиль студента | implemented | Контракт 09 (`variant/level/updatedBy/name`), лимит попыток и SQL-тест «без N+1» сделаны |
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта | | Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
| Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет | | Аудит | implemented | `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL/GROUP_ARCHIVE/GROUP_RESTORE` проверены интеграционным тестом |
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение | | Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён | | CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе | | Производительность | partial | Keyset-аудит, пакетные лабы/группы/сдачи, пагинация групп и плоского списка материалов, лимит попыток профиля; остаются visual regression и SLA-нагрузка |
| LDD | implemented | 59 claims (27 P0 / 32 P1), 59/59 proven после release; `check` доказывает P0/unit, runtime P1 — release | | LDD | implemented | 66 claims (27 P0 / 39 P1); `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена | | Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 | | «Контингент» | out of scope | Исключён решением 2026-10-28 |
@@ -87,15 +87,47 @@
- Занятый логин и неактивная целевая группа возвращают `applied=false` - Занятый логин и неактивная целевая группа возвращают `applied=false`
с номером строки и не создают частичных записей. с номером строки и не создают частичных записей.
- Тесты: `GroupServiceIntegrationTest#import reports occupied login...`, - Тесты: `GroupServiceIntegrationTest#import reports occupied login...`,
`#import reports inactive target group...`. `#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. Остаточные отличия от цели ## 3. Остаточные отличия от цели
### P1 ### P1
- **Профиль: поля контракта.** URL уже используют `courseNo`, но часть полей
`variant/level/grade` и авторы комментариев отличаются от docs/09; нужен
либо код к контракту, либо официальное обновление контракта.
- **Backup restore на staging** не подтверждён артефактом/логом. - **Backup restore на staging** не подтверждён артефактом/логом.
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён. - **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
@@ -109,17 +141,10 @@
### P2 ### P2
- **Импорт: гонка и XLSX-отчёт.** Preflight закрывает известные конфликты, - **Импорт: гонка.** Preflight закрывает известные конфликты, но гонка с
но гонка с параллельной вставкой логина всё ещё даёт общий 409; XLSX-строки параллельной вставкой логина всё ещё даёт общий 409 вместо строки отчёта.
новых preflight-тестов не покрыты (CSV покрыт). - **Визуальная регрессия.** Playwright проверяет сценарии и мобильный viewport,
- Когорта: поля всё ещё nullable, уникальность частичная; нужен preflight и но screenshot-diff нет.
финальный `SET NOT NULL`/полный unique index после ручного разбора legacy.
- Нет восстановления группы из архива (сейчас архив односторонний).
- Нет пагинации списков материалов и групп (аудит и профиль уже лучше).
- Попытки сдачи в профиле не ограничены/не пагинированы.
- Сдачи в ведомости запрашиваются по выбранной лабораторной, а не пакетно.
- Нет визуальной регрессии и полного мобильного набора E2E.
- Нет тестов на audit-события `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL`.
- Mutation testing не 100%, нагрузочный smoke не SLA. - Mutation testing не 100%, нагрузочный smoke не SLA.
### Операционные ### Операционные
@@ -142,16 +167,19 @@
## 5. LDD ## 5. LDD
- Реестр: `docs/evidence/claims.json`, **59 claims** (27 P0 / 32 P1). - Реестр: `docs/evidence/claims.json`, **66 claims** (27 P0 / 39 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0. - `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **59/59 claims**, 385 тестов, mutation - `./scripts/ldd-release.sh` — PASS: **66/66 claims**, 398 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18, `CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с. runtime/load p95 < 1 с.
## 6. Следующий порядок работ ## 6. Следующий порядок работ
1. Закрыть P1: row-level ошибки импорта, догон повышения, контракт профиля, 1. Остались операционные P1: restore на staging и первый реальный
staging restore, первый CI pipeline. Woodpecker pipeline.
2. Закрыть P2: финальная миграция когорты, unarchive, пагинация, audit-тесты, 2. P2: гонка импорта, визуальная регрессия, повышение mutation-покрытия
визуальная регрессия. и SLA-нагрузка.
3. Прогнать полный release и обновить `evidence/baseline-*`. 3. Перед production: ручной разбор legacy-когорт по SQL из `deploy/DEPLOY.md`,
затем backup и деплой.
4. После каждого изменения прогонять `./scripts/ldd-release.sh` и обновлять
`docs/evidence/baseline-2026-10-29.md`.

View File

@@ -27,7 +27,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения | | [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 59 claims, 385 тестов, 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, миграции, доступ, статусы, повышение | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе ## Кратко о системе

View File

@@ -87,10 +87,10 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков: рисков:
- **385 автоматических тестов**, 0 failures/errors; - **398 автоматических тестов**, 0 failures/errors;
- **59 claims**: 27 P0 + 32 P1, **59/59 proven, P0 gaps = 0**. P0 — доступы, - **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, контракты, импорт, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
копирование лаб, keyset-пагинация, пакетные детали лабораторных, копирование лаб, keyset-пагинация, пакетные детали лабораторных,
абсолютный потолок сессии, runtime, mutation и browser E2E; абсолютный потолок сессии, runtime, mutation и browser E2E;
@@ -114,4 +114,4 @@ Kotlin-модуль, поэтому DTO с default-значениями треб
реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок
access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и
«ведущие нули» больше не являются открытыми рисками. «ведущие нули» больше не являются открытыми рисками.
Результаты — в `baseline-2026-10-27.md`. Результаты — в `baseline-2026-10-29.md`; предыдущий срез — `baseline-2026-10-27.md`.

View File

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

View File

@@ -435,12 +435,12 @@
"criticality": "P1", "criticality": "P1",
"status": "active", "status": "active",
"requirement": "docs/16-stage6-report.md, §2.2", "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 идемпотентна.", "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-наличие таблиц/колонок/индексов", "oracle": "postflight checks = required; повторные V13/V14 не падают",
"runtime": [ "runtime": [
"migration.master_upgrade" "migration.master_upgrade"
], ],
"limitations": "Запускается отдельным скриптом на одноразовой БД; не входит в unit-CI." "limitations": "V14 намеренно блокирует legacy-группы с неразобранной когортой."
}, },
{ {
"id": "LDD-RUNTIME-001", "id": "LDD-RUNTIME-001",
@@ -872,6 +872,108 @@
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()" "ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()"
], ],
"limitations": "Внутренние REST-контракты остаются UUID; form payload создания/копирования лабораторной тоже использует UUID." "limitations": "Внутренние REST-контракты остаются UUID; form payload создания/копирования лабораторной тоже использует UUID."
},
{
"id": "LDD-PROFILE-002",
"title": "Профиль соответствует контракту docs/09 и ограничивает попытки",
"criticality": "P1",
"status": "active",
"requirement": "docs/09-teacher-student-profile.md, §3, §7",
"claim": "Профиль отдаёт variant.id, level.id/code, grade.updatedBy и author.name; число попыток отражается полностью, а список ограничивается параметром submissionLimit; профиль не делает запрос на каждую лабу.",
"oracle": "JSON-поля контракта; attemptsCount/submissionsTotal; число SQL-запросов не растёт с числом лабораторных",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher sees unpublished grade, authors and all submission attempts()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile limits submission attempts but reports total()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile uses one query per data kind instead of per lab()",
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()",
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#lookup includes student full name and leaves staff name empty()"
],
"limitations": "По умолчанию возвращаются последние 50 попыток; UI показывает, сколько скрыто."
},
{
"id": "LDD-GROUP-001",
"title": "Группа возвращается из архива",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.3",
"claim": "Архивную группу можно вернуть в ACTIVE; повторный возврат активной группы отклоняется, состав и история не меняются.",
"oracle": "status меняется ARCHIVED→ACTIVE; второй restore — 400; число студентов и история сохраняются",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#restores archived group and rejects repeated restore()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Возврат выполняется со страницы группы; отдельного массового восстановления нет."
},
{
"id": "LDD-GROUP-002",
"title": "Финальные ограничения когорты",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.2; docs/08-groups-courses-plan.md, этап 1",
"claim": "Все поля когорты NOT NULL, уникальность полная (без partial WHERE), legacy-строки блокируют миграцию с явной ошибкой.",
"oracle": "information_schema.is_nullable=NO для 5 полей; groups_cohort_unique без WHERE; master→branch migration PASS",
"evidence": [],
"runtime": [
"migration.master_upgrade"
],
"limitations": "Перед деплоем на реальных данных нужен ручной разбор legacy-групп; запрос для проверки есть в V14."
},
{
"id": "LDD-IMPORT-004",
"title": "XLSX-импорт отчитывается по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/08-groups-courses-plan.md, этап 1; docs/18 §3",
"claim": "Для XLSX занятый логин и неактивная целевая группа возвращают applied=false, номер строки и не создают частичных записей.",
"oracle": "ImportResult.errors.row=2, applied=false; студент не создан",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports occupied login before writing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports inactive target group before writing()"
],
"limitations": "Гонка с параллельной вставкой логина вне preflight всё ещё может дать общий 409."
},
{
"id": "LDD-AUDIT-006",
"title": "Операции с группами и студентами пишут аудит",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9; docs/08-groups-courses-plan.md, этап 1",
"claim": "GROUP_CREATE, STUDENT_CREATE, GROUP_ENROLL, GROUP_IMPORT, GROUP_ARCHIVE и GROUP_RESTORE попадают в журнал с актором и деталями.",
"oracle": "audit_events содержит все шесть action; actorAccountId/actorLogin заполнены",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Проверяются успешные операции; отказы валидации аудит не пишут."
},
{
"id": "LDD-PERF-003",
"title": "Пакетные сдачи и пагинация списков",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §10; docs/18 §3",
"claim": "Ведомость получает последние сдачи по всем выбранным лабам одним запросом; группы и плоский список материалов поддерживают limit/offset.",
"oracle": "одна выборка submissions на набор labId; страницы групп/материалов не пересекаются и не теряют записи",
"evidence": [
"ru.ysu.lms.progress.SubmissionBatchIntegrationTest#latest submissions are grouped by lab in one call()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#lists groups page by page and returns all without limit()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#admin article list supports offset and limit()"
],
"limitations": "Дерево курса по-прежнему загружается целиком для навигации; пагинируются плоские списки."
},
{
"id": "LDD-GROUP-003",
"title": "История членства видна на странице группы",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §3; docs/18 §3",
"claim": "Админ видит последние события membership_history по группе: студент, действие, откуда/куда, причина и время МСК.",
"oracle": "GET /api/v1/groups/{id}/history отдаёт studentName/fromGroupName/toGroupName; шаблон показывает блок истории",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#imports students and records membership history()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders membership history and restore action for archived group()"
],
"limitations": "Показаны последние 200 событий; фильтра/пагинации по истории нет."
} }
] ]
} }

View File

@@ -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/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/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/V13__audit_keyset_indexes.sql
cat services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql
echo 'SET search_path TO academic;' 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/V6__courses.sql
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.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'; WHERE schemaname='identity' AND indexname='audit_events_occurred_id_idx';
SELECT 'students=' || count(*) FROM students; SELECT 'students=' || count(*) FROM students;
SELECT 'groups=' || count(*) FROM groups; 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; SELECT 'history=' || count(*) FROM membership_history;
SET search_path TO academic; SET search_path TO academic;
SELECT 'labs_course_id=' || count(*) FROM information_schema.columns 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") 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 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" V13_TWICE="passed"
else else
V13_TWICE="failed" V13_TWICE="failed"
FAILED=1 FAILED=1
fi 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/^/ /' printf '%s\n' "$RESULT" | sed 's/^/ /'
python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" <<'PY' python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" "$V14_TWICE" <<'PY'
import json import json
import sys 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 = { required = {
"audit_events=1", "audit_events=1",
"refresh_tokens=1", "refresh_tokens=1",
@@ -173,6 +187,8 @@ required = {
"audit_keyset_idx=1", "audit_keyset_idx=1",
"students=1", "students=1",
"groups=1", "groups=1",
"cohort_not_null=5",
"cohort_unique_full=1",
"history=1", "history=1",
"labs_course_id=1", "labs_course_id=1",
"chapters_course_id=1", "chapters_course_id=1",
@@ -186,16 +202,19 @@ required = {
} }
lines = {line.strip() for line in result.splitlines() if line.strip()} lines = {line.strip() for line in result.splitlines() if line.strip()}
missing = sorted(required - lines) missing = sorted(required - lines)
failed = bool(missing) or v13 != "passed" failed = bool(missing) or v13 != "passed" or v14 != "passed"
if missing: if missing:
status = "failed" status = "failed"
detail = "postflight не подтвердил: " + ", ".join(missing) detail = "postflight не подтвердил: " + ", ".join(missing)
elif v13 != "passed": elif v13 != "passed":
status = "failed" status = "failed"
detail = "V13 не применилась повторно" detail = "V13 не применилась повторно"
elif v14 != "passed":
status = "failed"
detail = "V14 не применилась повторно"
else: else:
status = "passed" status = "passed"
detail = "master->branch: все postflight-проверки и повторная V13 успешны" detail = "master->branch: postflight, повторные V13 и V14 успешны"
checks = [{"id": "migration.master_upgrade", "status": status, "detail": detail}] checks = [{"id": "migration.master_upgrade", "status": status, "detail": detail}]
with open(out, "w", encoding="utf-8") as fh: with open(out, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat() now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()

View File

@@ -359,24 +359,31 @@ class ContentService(
articles.save(a) articles.save(a)
} }
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true) @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 chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id } val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a -> val all =
val sec = secById[a.sectionId] ?: return@mapNotNull null articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val ch = chById[sec.chapterId] ?: return@mapNotNull null val sec = secById[a.sectionId] ?: return@mapNotNull null
ContentArticleView( val ch = chById[sec.chapterId] ?: return@mapNotNull null
id = a.id.toString(), ContentArticleView(
chapterSlug = ch.slug, id = a.id.toString(),
chapterTitle = ch.title, chapterSlug = ch.slug,
sectionSlug = sec.slug, chapterTitle = ch.title,
sectionTitle = sec.title, sectionSlug = sec.slug,
slug = a.slug, sectionTitle = sec.title,
title = a.title, slug = a.slug,
bodyMd = a.bodyMd, 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 @Transactional
@@ -385,6 +392,10 @@ class ContentService(
bytes: ByteArray, bytes: ByteArray,
sync: Boolean = true, sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync) ): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
} }
/** /**

View File

@@ -158,7 +158,10 @@ class ContentAdminController(
} }
@GetMapping("/articles") @GetMapping("/articles")
fun list() = contentService.listAll() fun list(
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(defaultValue = "0") limit: Int,
) = contentService.listAll(offset, limit)
@PostMapping("/chapters") @PostMapping("/chapters")
fun createChapter( fun createChapter(

View File

@@ -92,6 +92,21 @@ class CourseContentIntegrationTest {
.build() .build()
} }
@Test
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 @Test
fun `anonymous tree contains only global chapters with ALL audience`() { fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL") val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,10 @@ class GroupController(
private val auditService: AuditService, private val auditService: AuditService,
) { ) {
@GetMapping @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 @PostMapping
fun create( fun create(
@@ -148,6 +151,29 @@ class GroupController(
return detail 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") @GetMapping("/{id}/credentials")
fun credentials( fun credentials(
@PathVariable id: UUID, @PathVariable id: UUID,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster package ru.ysu.lms.identity.roster
import org.springframework.data.domain.PageRequest
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -34,8 +35,22 @@ class GroupService(
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val cohortResolver: GroupCohortResolver, private val cohortResolver: GroupCohortResolver,
) { ) {
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
@Transactional(readOnly = true) @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 @Transactional
fun createStudent( fun createStudent(
@@ -262,6 +277,43 @@ class GroupService(
return detail(groupId) 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.orEmpty(),
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.ACTIVE) {
throw BadRequestException("Группа ${group.name} уже активна")
}
group.status = GroupStatus.ACTIVE
groups.save(group)
return detail(groupId)
}
/** /**
* Удаляет студента из identity: зачисление, профиль, аккаунт. * Удаляет студента из identity: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента. * [confirm] должен совпадать с логином аккаунта или ФИО студента.
@@ -350,6 +402,10 @@ class GroupService(
} }
} }
private companion object {
const val MAX_GROUP_PAGE = 500
}
private fun activeGroup(groupId: UUID): GroupEntity { private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) { if (group.status != GroupStatus.ACTIVE) {

View File

@@ -85,6 +85,20 @@ data class ResetPasswordResponse(
val password: String, 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( data class StudentCandidate(
val id: String, val id: String,
val fullName: String, val fullName: String,

View File

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

View File

@@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.core.authority.SimpleGrantedAuthority 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.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertyRegistry
@@ -25,10 +29,13 @@ import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers 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.repo.AccountRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest 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.identity.roster.GroupService
import ru.ysu.lms.security.LmsRole
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.UUID import java.util.UUID
@@ -63,6 +70,8 @@ class AuditIntegrationTest {
@Autowired lateinit var groupService: GroupService @Autowired lateinit var groupService: GroupService
@Autowired lateinit var groupController: GroupController
@Autowired lateinit var context: WebApplicationContext @Autowired lateinit var context: WebApplicationContext
private val objectMapper = ObjectMapper() private val objectMapper = ObjectMapper()
@@ -378,6 +387,80 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent) 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( private data class AuditPage(
val details: List<String>, val details: List<String>,
val cursor: String?, val cursor: String?,

View File

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

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster 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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -13,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.AuthService
@@ -26,6 +28,7 @@ import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole import ru.ysu.lms.security.LmsRole
import java.io.ByteArrayOutputStream
import java.util.UUID import java.util.UUID
@SpringBootTest @SpringBootTest
@@ -89,6 +92,40 @@ class GroupServiceIntegrationTest {
assertTrue(error is ConflictException) 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 @Test
fun `imports students and records membership history`() { fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null) val result = importService.import("group.csv", csv(validRows()), null)
@@ -106,6 +143,11 @@ class GroupServiceIntegrationTest {
assertEquals(2025.toShort(), student.enrollmentYear) assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId) val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action) 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 @Test
@@ -272,6 +314,64 @@ class GroupServiceIntegrationTest {
assertNull(students.findByStudentNo("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 @Test
fun `import reports inactive target group and writes nothing`() { fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026)) val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
@@ -324,4 +424,22 @@ class GroupServiceIntegrationTest {
""".trimIndent() """.trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray() private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
private fun xlsx(row: String): ByteArray {
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
val values = row.split(";")
return XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("group")
sheet.createRow(0).apply {
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
}
sheet.createRow(1).apply {
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
}
ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
}
}
} }

View File

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

View File

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

View File

@@ -159,6 +159,19 @@ class SubmissionService(
return perStudent.values.map { toDto(it, filesOf(it)) } 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). */ /** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt") private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")

View File

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

View File

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

View File

@@ -57,8 +57,10 @@ class StudentProfileService(
callerId: UUID, callerId: UUID,
callerRole: String, callerRole: String,
token: String, token: String,
submissionLimit: Int = DEFAULT_SUBMISSION_LIMIT,
): StudentCourseProfile { ): StudentCourseProfile {
val role = normalizeRole(callerRole) val role = normalizeRole(callerRole)
val effectiveLimit = submissionLimit.coerceIn(1, MAX_SUBMISSION_LIMIT)
val course = academic.getCourse(courseId.toString(), token) val course = academic.getCourse(courseId.toString(), token)
val context = identity.getStudentContext(studentId.toString()) val context = identity.getStudentContext(studentId.toString())
requireProfileAccess(course, context, studentId, callerId, role) requireProfileAccess(course, context, studentId, callerId, role)
@@ -85,7 +87,7 @@ class StudentProfileService(
allComments[labId].orEmpty().forEach { it.authorAccountId?.let(authorIds::add) } allComments[labId].orEmpty().forEach { it.authorAccountId?.let(authorIds::add) }
} }
} }
val logins = lookupLogins(authorIds) val authors = lookupAuthors(authorIds)
val profileLabs = val profileLabs =
labs.map { (labId, lab) -> labs.map { (labId, lab) ->
@@ -102,7 +104,8 @@ class StudentProfileService(
levelId = levelsByLab[labId], levelId = levelsByLab[labId],
published = labId in published, published = labId in published,
showUnpublished = showUnpublished, showUnpublished = showUnpublished,
logins = logins, authors = authors,
submissionLimit = effectiveLimit,
) )
} }
@@ -266,12 +269,15 @@ class StudentProfileService(
return all 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() if (authorIds.isEmpty()) return emptyMap()
val accounts = runCatching { identity.lookupAccounts(authorIds.map(UUID::toString)) }.getOrDefault(emptyList()) val accounts = runCatching { identity.lookupAccounts(authorIds.map(UUID::toString)) }.getOrDefault(emptyList())
return accounts return accounts
.filter { it.id.isNotBlank() } .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") @Suppress("LongParameterList")
@@ -288,12 +294,14 @@ class StudentProfileService(
levelId: UUID?, levelId: UUID?,
published: Boolean, published: Boolean,
showUnpublished: Boolean, showUnpublished: Boolean,
logins: Map<String, String>, authors: Map<String, ProfileAuthor>,
submissionLimit: Int,
): ProfileLab { ): ProfileLab {
val canSeeGrade = showUnpublished || published val canSeeGrade = showUnpublished || published
val sortedSubmissions = submissions.sortedByDescending { it.seq } val allSubmissions = submissions.sortedByDescending { it.seq }
val sortedSubmissions = allSubmissions.take(submissionLimit)
val lastSubmissionAt = 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 = val gradeDto =
grade grade
?.takeIf { canSeeGrade } ?.takeIf { canSeeGrade }
@@ -302,7 +310,7 @@ class StudentProfileService(
score = it.score.toPlainString(), score = it.score.toPlainString(),
published = published, published = published,
generalComment = it.generalComment, generalComment = it.generalComment,
author = authorOf(it.updatedByAccountId, logins), updatedBy = authorOf(it.updatedByAccountId, authors),
) )
} }
val commentDtos = val commentDtos =
@@ -314,7 +322,7 @@ class StudentProfileService(
id = it.id.toString(), id = it.id.toString(),
text = it.text, text = it.text,
tone = it.tone, tone = it.tone,
author = authorOf(it.authorAccountId, logins), author = authorOf(it.authorAccountId, authors),
createdAt = it.createdAt.toString(), createdAt = it.createdAt.toString(),
) )
} }
@@ -328,11 +336,12 @@ class StudentProfileService(
deadlineAt = lab.deadlineAt, deadlineAt = lab.deadlineAt,
variant = variantOf(lab, variantId), variant = variantOf(lab, variantId),
level = levelOf(lab, levelId), level = levelOf(lab, levelId),
attemptsCount = sortedSubmissions.size, attemptsCount = allSubmissions.size,
lastSubmissionAt = lastSubmissionAt, lastSubmissionAt = lastSubmissionAt,
grade = gradeDto, grade = gradeDto,
comments = commentDtos, comments = commentDtos,
defense = defense?.let { toDefense(it) }, defense = defense?.let { toDefense(it) },
submissionsTotal = allSubmissions.size,
submissions = submissions =
sortedSubmissions.map { s -> sortedSubmissions.map { s ->
ProfileSubmission( ProfileSubmission(
@@ -357,7 +366,7 @@ class StudentProfileService(
.flatMap { it.variants.asSequence() } .flatMap { it.variants.asSequence() }
.firstOrNull { it.id == variantId.toString() } .firstOrNull { it.id == variantId.toString() }
?: return null ?: return null
return ProfileVariant(variant.number, variant.title) return ProfileVariant(variant.id.ifBlank { variantId.toString() }, variant.number, variant.title)
} }
private fun levelOf( private fun levelOf(
@@ -366,7 +375,7 @@ class StudentProfileService(
): ProfileLevel? { ): ProfileLevel? {
if (levelId == null) return null if (levelId == null) return null
val level = lab.levels.firstOrNull { it.id == levelId.toString() } ?: 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 { private fun toDefense(session: DefenseSession): ProfileDefense {
@@ -422,12 +431,8 @@ class StudentProfileService(
private fun authorOf( private fun authorOf(
authorId: UUID?, authorId: UUID?,
logins: Map<String, String>, authors: Map<String, ProfileAuthor>,
): ProfileAuthor? = ): ProfileAuthor? = authorId?.let { authors[it.toString()] ?: ProfileAuthor(it.toString(), UNKNOWN_AUTHOR) }
authorId?.let {
val key = it.toString()
ProfileAuthor(key, logins[key] ?: UNKNOWN_AUTHOR)
}
private fun normalizeRole(role: String): String = role.removePrefix("ROLE_").uppercase() private fun normalizeRole(role: String): String = role.removePrefix("ROLE_").uppercase()
@@ -436,5 +441,7 @@ class StudentProfileService(
const val ROLE_TEACHER = "TEACHER" const val ROLE_TEACHER = "TEACHER"
const val ROLE_STUDENT = "STUDENT" const val ROLE_STUDENT = "STUDENT"
const val UNKNOWN_AUTHOR = "—" const val UNKNOWN_AUTHOR = "—"
const val DEFAULT_SUBMISSION_LIMIT = 50
const val MAX_SUBMISSION_LIMIT = 500
} }
} }

View File

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

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import jakarta.persistence.EntityManagerFactory
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy import org.assertj.core.api.Assertions.assertThatThrownBy
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
@@ -31,7 +33,12 @@ import java.util.UUID
* Проверяются доступ, публикация оценок, все попытки сдач и скачивание. * Проверяются доступ, публикация оценок, все попытки сдач и скачивание.
*/ */
@Testcontainers @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 { class StudentProfileIntegrationTest {
companion object { companion object {
@Container @Container
@@ -65,6 +72,9 @@ class StudentProfileIntegrationTest {
@Autowired @Autowired
private lateinit var storage: SubmissionStorage private lateinit var storage: SubmissionStorage
@Autowired
private lateinit var entityManagerFactory: EntityManagerFactory
@MockitoBean @MockitoBean
private lateinit var academic: AcademicClient private lateinit var academic: AcademicClient
@@ -72,6 +82,25 @@ class StudentProfileIntegrationTest {
private lateinit var identity: IdentityClient private lateinit var identity: IdentityClient
@Test @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`() { fun `teacher sees unpublished grade, authors and all submission attempts`() {
val courseId = UUID.randomUUID() val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID() val groupId = UUID.randomUUID()
@@ -111,9 +140,13 @@ class StudentProfileIntegrationTest {
assertThat(profile.summary.averageScore).isEqualTo(4.5) assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1) assertThat(profile.labs).hasSize(1)
val lab = profile.labs.single() val lab = profile.labs.single()
assertThat(lab.variant?.id).isEqualTo(variantId.toString())
assertThat(lab.variant?.number).isEqualTo("7") 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.level?.title).isEqualTo("Базовый")
assertThat(lab.attemptsCount).isEqualTo(2) assertThat(lab.attemptsCount).isEqualTo(2)
assertThat(lab.submissionsTotal).isEqualTo(2)
assertThat(lab.submissions.map { it.seq }).containsExactly(2, 1) assertThat(lab.submissions.map { it.seq }).containsExactly(2, 1)
assertThat(lab.submissions.first().files).containsExactlyInAnyOrder("main.c", "README.md") assertThat(lab.submissions.first().files).containsExactlyInAnyOrder("main.c", "README.md")
assertThat(lab.submissions.first().downloadUrl) assertThat(lab.submissions.first().downloadUrl)
@@ -121,10 +154,10 @@ class StudentProfileIntegrationTest {
val grade = checkNotNull(lab.grade) val grade = checkNotNull(lab.grade)
assertThat(grade.score).isEqualTo("4.5") assertThat(grade.score).isEqualTo("4.5")
assertThat(grade.published).isFalse() assertThat(grade.published).isFalse()
assertThat(grade.author?.login).isEqualTo("teacher_login") assertThat(grade.updatedBy?.name).isEqualTo("teacher_login")
assertThat(lab.comments).hasSize(1) assertThat(lab.comments).hasSize(1)
val commentAuthor = lab.comments.single().author val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login") assertThat(commentAuthor?.name).isEqualTo("teacher_login")
val download = val download =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
@@ -236,6 +269,27 @@ class StudentProfileIntegrationTest {
assertThat(download.filename).endsWith(".zip") 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)
}
private fun stubCourse( private fun stubCourse(
courseId: UUID, courseId: UUID,
groupId: UUID, groupId: UUID,

View File

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

View File

@@ -10,6 +10,7 @@ import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.archiveGroup import ru.ysu.lms.web.api.archiveGroup
import ru.ysu.lms.web.api.restoreGroup
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
/** Архив группы без массового изменения статусов студентов. */ /** Архив группы без массового изменения статусов студентов. */
@@ -20,6 +21,21 @@ class AdminGroupArchiveController(
markdown: MarkdownRenderer, markdown: MarkdownRenderer,
mapper: ObjectMapper, mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) { ) : 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") @PostMapping("/{id}/archive")
fun archive( fun archive(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer 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.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto import ru.ysu.lms.web.api.CourseDetailDto
@@ -27,6 +28,7 @@ import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.LabDataDto import ru.ysu.lms.web.api.LabDataDto
import ru.ysu.lms.web.api.LabSummaryDto 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.PublicationSetPayload
import ru.ysu.lms.web.api.PublicationStateDto import ru.ysu.lms.web.api.PublicationStateDto
import ru.ysu.lms.web.api.StudentCandidateDto import ru.ysu.lms.web.api.StudentCandidateDto
@@ -42,6 +44,7 @@ import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.getLevels import ru.ysu.lms.web.api.getLevels
import ru.ysu.lms.web.api.groupCandidates import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail 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.groupLabData
import ru.ysu.lms.web.api.importGroup import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labDetail
@@ -72,6 +75,10 @@ class AdminGroupController(
) : AdminSupport(api, markdown, mapper) { ) : AdminSupport(api, markdown, mapper) {
private val log = LoggerFactory.getLogger(AdminGroupController::class.java) private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
private companion object {
const val GROUP_PAGE_SIZE = 50
}
@GetMapping @GetMapping
fun dashboard( fun dashboard(
session: HttpSession, session: HttpSession,
@@ -101,10 +108,15 @@ class AdminGroupController(
@GetMapping("/groups") @GetMapping("/groups")
fun groups( fun groups(
@RequestParam(defaultValue = "0") page: Int,
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
model.addAttribute("groups", api.listGroups(token(session))) val safePage = page.coerceAtLeast(0)
val rows = api.listGroups(token(session), safePage, GROUP_PAGE_SIZE)
model.addAttribute("groups", rows)
model.addAttribute("page", safePage)
model.addAttribute("hasNext", rows.size == GROUP_PAGE_SIZE)
return "admin/groups" return "admin/groups"
} }
@@ -203,7 +215,11 @@ class AdminGroupController(
val grades = api.listGrades(token) val grades = api.listGrades(token)
val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId } val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) 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)
} }
/** /**
@@ -241,7 +257,7 @@ class AdminGroupController(
} else { } else {
api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId } 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") @Suppress("LongParameterList")
@@ -252,6 +268,7 @@ class AdminGroupController(
dataByLab: Map<String, LabDataDto>, dataByLab: Map<String, LabDataDto>,
attestations: List<GroupAttestation>, attestations: List<GroupAttestation>,
candidates: List<StudentCandidateDto>, candidates: List<StudentCandidateDto>,
history: List<MembershipHistoryDto>,
): GroupViewModel { ): GroupViewModel {
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -307,6 +324,7 @@ class AdminGroupController(
labPublication, labPublication,
submissionCounts, submissionCounts,
attestations, attestations,
history,
) )
} }
@@ -402,6 +420,7 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>, val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>, val attestations: List<GroupAttestation>,
val history: List<MembershipHistoryDto>,
) )
private fun fillGroupModel( private fun fillGroupModel(
@@ -415,6 +434,7 @@ class AdminGroupController(
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations) model.addAttribute("attestations", view.attestations)
model.addAttribute("history", view.history)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }

View File

@@ -35,10 +35,25 @@ fun ApiClient.contentArticle(
.body(ContentArticleDto::class.java) .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>>() {} val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call { 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) (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve() .retrieve()
.body(type) .body(type)

View File

@@ -8,13 +8,24 @@ import org.springframework.util.LinkedMultiValueMap
import java.net.URLDecoder import java.net.URLDecoder
import java.nio.charset.StandardCharsets 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>>() {} val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call { return call {
client client
.get() .get()
.uri("/api/v1/groups") .uri { builder ->
.header(HttpHeaders.AUTHORIZATION, bearer(token)) 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() .retrieve()
.body(type) .body(type)
} }
@@ -165,6 +176,34 @@ fun ApiClient.archiveGroup(
.body(GroupDetailDto::class.java) .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( fun ApiClient.credentials(
token: String, token: String,
groupId: String, groupId: String,

View File

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

View File

@@ -132,6 +132,22 @@ data class GroupDetailDto(
val students: List<StudentRowDto> = emptyList(), 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( data class StudentCandidateDto(
val id: String = "", val id: String = "",
val fullName: String = "", val fullName: String = "",
@@ -760,6 +776,7 @@ data class ProfileLabDto(
val grade: ProfileGradeDto? = null, val grade: ProfileGradeDto? = null,
val comments: List<ProfileCommentDto> = emptyList(), val comments: List<ProfileCommentDto> = emptyList(),
val defense: ProfileDefenseDto? = null, val defense: ProfileDefenseDto? = null,
val submissionsTotal: Int = 0,
val submissions: List<ProfileSubmissionDto> = emptyList(), val submissions: List<ProfileSubmissionDto> = emptyList(),
) { ) {
/** Локальные для UI значения, заполняются контроллером. */ /** Локальные для UI значения, заполняются контроллером. */
@@ -769,12 +786,15 @@ data class ProfileLabDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileVariantDto( data class ProfileVariantDto(
val id: String = "",
val number: String = "", val number: String = "",
val title: String = "", val title: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileLevelDto( data class ProfileLevelDto(
val id: String = "",
val code: String = "",
val title: String = "", val title: String = "",
) )
@@ -784,7 +804,7 @@ data class ProfileGradeDto(
val score: Any? = null, val score: Any? = null,
val published: Boolean = false, val published: Boolean = false,
val generalComment: String = "", val generalComment: String = "",
val author: ProfileAuthorDto? = null, val updatedBy: ProfileAuthorDto? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -825,7 +845,7 @@ data class ProfileSubmissionDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileAuthorDto( data class ProfileAuthorDto(
val id: String = "", val id: String = "",
val login: String = "", val name: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -32,6 +32,11 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">В архив</button> <button class="btn btn-secondary" type="submit">В архив</button>
</form> </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>
</div> </div>
@@ -402,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}"> <dialog id="modal-add-student" class="dlg" th:if="${!teacherMode}">
<div class="dlg-header"> <div class="dlg-header">

View File

@@ -56,6 +56,15 @@
</table> </table>
</div> </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"> <dialog id="modal-create-group" class="dlg">
<div class="dlg-header"> <div class="dlg-header">
<h2 class="dlg-title">Новая группа</h2> <h2 class="dlg-title">Новая группа</h2>

View File

@@ -129,8 +129,8 @@
<h4 class="group-title">Оценка</h4> <h4 class="group-title">Оценка</h4>
<p class="mb-2" th:if="${lab.grade != null}"> <p class="mb-2" th:if="${lab.grade != null}">
<span class="score-big" th:text="${lab.grade.score}">4.5</span> <span class="score-big" th:text="${lab.grade.score}">4.5</span>
<span class="text-sm text-muted" th:if="${lab.grade.author != null}" <span class="text-sm text-muted" th:if="${lab.grade.updatedBy != null}"
th:text="|Автор: ${lab.grade.author.login}|"></span> th:text="|Автор: ${lab.grade.updatedBy.name}|"></span>
</p> </p>
<p class="text-muted text-sm" th:if="${lab.grade == null}">Оценка ещё не выставлена.</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)}"> <div class="general-comment" th:if="${lab.grade != null and !#strings.isEmpty(lab.grade.generalComment)}">
@@ -169,7 +169,7 @@
th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный</span> th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный</span>
<span th:text="${comment.text}">—</span> <span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}" <span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span> th:text="|— ${comment.author.name}|"></span>
<span class="text-sm text-muted" th:if="${comment.author == null}">— автор не указан</span> <span class="text-sm text-muted" th:if="${comment.author == null}">— автор не указан</span>
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}" <span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span> th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
@@ -178,6 +178,9 @@
<h4 class="group-title mt-4">Попытки сдачи</h4> <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-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)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(lab.submissions)}">
<table> <table>
<thead> <thead>

View File

@@ -15,6 +15,7 @@ import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.ImportErrorDto import ru.ysu.lms.web.api.ImportErrorDto
import ru.ysu.lms.web.api.ImportResultDto import ru.ysu.lms.web.api.ImportResultDto
import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.MembershipHistoryDto
import ru.ysu.lms.web.api.PromotionItemDto import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto import ru.ysu.lms.web.api.PromotionRunDetailDto
import ru.ysu.lms.web.api.PromotionRunSummaryDto import ru.ysu.lms.web.api.PromotionRunSummaryDto
@@ -215,6 +216,55 @@ class AdminStage4TemplateTest {
assertThat(html).doesNotContain("Убрать из группы") assertThat(html).doesNotContain("Убрать из группы")
} }
@Test
fun `renders membership history and restore action for archived group`() {
val group =
GroupDetailDto(
id = "g1",
name = "ПИ-21БО",
status = "ARCHIVED",
students = emptyList(),
)
val history =
listOf(
MembershipHistoryDto(
id = "h1",
studentId = "s1",
studentName = "Иванов Иван",
fromGroupId = "g0",
fromGroupName = "ПИ-11БО",
toGroupId = "g1",
toGroupName = "ПИ-21БО",
action = "TRANSFER",
reason = "перевод",
at = "2026-10-01T10:00:00Z",
).apply { atMsk = "01.10.2026 13:00" },
)
val model =
linkedMapOf<String, Any?>(
"group" to group,
"labs" to emptyList<LabSummaryDto>(),
"candidates" to emptyList<Any>(),
"studentGrades" to emptyList<GroupStudentRow>(),
"labPublication" to emptyMap<String, GroupLabPublication>(),
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseNo" to null,
"teacherView" to false,
"history" to history,
)
val html = render("admin/group", model)
assertThat(html).contains("История членства")
assertThat(html).contains("Иванов Иван")
assertThat(html).contains("переведён")
assertThat(html).contains("ПИ-11БО")
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("/admin/groups/g1/restore")
}
@Test @Test
fun `group import form requires explicit restoration flag`() { fun `group import form requires explicit restoration flag`() {
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>())) val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))

View File

@@ -19,16 +19,17 @@ class ProfileDtoJsonTest {
"defensesTotal": 1, "defensesReady": 0}, "defensesTotal": 1, "defensesReady": 0},
"labs": [{ "labs": [{
"id": "l1", "number": 1, "title": "ЛР", "deadlineAt": "2026-10-01T20:59:00Z", "id": "l1", "number": 1, "title": "ЛР", "deadlineAt": "2026-10-01T20:59:00Z",
"variant": {"number": "3", "title": "В-3"}, "variant": {"id": "v1", "number": "3", "title": "В-3"},
"level": {"title": "Базовый"}, "level": {"id": "lv1", "code": "base", "title": "Базовый"},
"attemptsCount": 1, "attemptsCount": 1,
"lastSubmissionAt": "2026-09-28T18:12:00Z", "lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {"score": "4.5", "published": true, "generalComment": "Ок", "grade": {"score": "4.5", "published": true, "generalComment": "Ок",
"author": {"id": "t1", "login": "petrov"}}, "updatedBy": {"id": "t1", "name": "Петров Пётр"}},
"comments": [{"id": "c1", "text": "Комментарий", "tone": "neutral", "comments": [{"id": "c1", "text": "Комментарий", "tone": "neutral",
"author": {"id": "t1", "login": "petrov"}, "createdAt": "2026-09-29T09:30:00Z"}], "author": {"id": "t1", "name": "Петров Пётр"}, "createdAt": "2026-09-29T09:30:00Z"}],
"defense": {"sessionId": "d1", "status": "ready", "readyReason": "timeout", "defense": {"sessionId": "d1", "status": "ready", "readyReason": "timeout",
"code": null, "assignmentMd": "Задание", "secondsLeft": null}, "code": null, "assignmentMd": "Задание", "secondsLeft": null},
"submissionsTotal": 1,
"submissions": [{"id": "sub1", "seq": 1, "createdAt": "2026-09-28T18:12:00Z", "submissions": [{"id": "sub1", "seq": 1, "createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c"], "downloadUrl": "/download"}] "files": ["main.c"], "downloadUrl": "/download"}]
}] }]
@@ -39,8 +40,13 @@ class ProfileDtoJsonTest {
assertThat(profile.student.fullName).isEqualTo("Иванов") assertThat(profile.student.fullName).isEqualTo("Иванов")
assertThat(profile.summary.averageScore).isEqualTo(4.5) assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1) assertThat(profile.labs).hasSize(1)
assertThat(profile.labs[0].grade?.score).isEqualTo("4.5") val lab = profile.labs.single()
assertThat(profile.labs[0].submissions[0].files).containsExactly("main.c") assertThat(lab.grade?.score).isEqualTo("4.5")
assertThat(lab.grade?.updatedBy?.name).isEqualTo("Петров Пётр")
assertThat(lab.variant?.id).isEqualTo("v1")
assertThat(lab.level?.code).isEqualTo("base")
assertThat(lab.submissionsTotal).isEqualTo(1)
assertThat(lab.submissions[0].files).containsExactly("main.c")
} }
@Test @Test

View File

@@ -48,6 +48,7 @@ class StudentProfileTemplateTest {
assertThat(html).contains("попыток: 2") assertThat(html).contains("попыток: 2")
assertThat(html).contains("4.5") assertThat(html).contains("4.5")
assertThat(html).contains("Хорошо") assertThat(html).contains("Хорошо")
assertThat(html).contains("Петров Пётр")
assertThat(html).contains("Разделите функцию на части") assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время") assertThat(html).contains("кончилось время")
assertThat(html).contains("main.c") assertThat(html).contains("main.c")
@@ -128,8 +129,8 @@ class StudentProfileTemplateTest {
number = 1, number = 1,
title = "Введение в C", title = "Введение в C",
deadlineAt = "2026-10-01T20:59:00Z", deadlineAt = "2026-10-01T20:59:00Z",
variant = ProfileVariantDto(number = "3", title = "Вариант 3"), variant = ProfileVariantDto(id = "v1", number = "3", title = "Вариант 3"),
level = ProfileLevelDto(title = "Базовый"), level = ProfileLevelDto(id = "lv1", code = "base", title = "Базовый"),
attemptsCount = 2, attemptsCount = 2,
lastSubmissionAt = "2026-09-28T18:12:00Z", lastSubmissionAt = "2026-09-28T18:12:00Z",
grade = sampleGrade(), grade = sampleGrade(),
@@ -146,7 +147,7 @@ class StudentProfileTemplateTest {
score = "4.5", score = "4.5",
published = true, published = true,
generalComment = "Хорошо", generalComment = "Хорошо",
author = ProfileAuthorDto(id = "t1", login = "petrov"), updatedBy = ProfileAuthorDto(id = "t1", name = "Петров Пётр"),
) )
private fun sampleComment(): ProfileCommentDto = private fun sampleComment(): ProfileCommentDto =
@@ -154,7 +155,7 @@ class StudentProfileTemplateTest {
id = "c1", id = "c1",
text = "Разделите функцию на части", text = "Разделите функцию на части",
tone = "neutral", tone = "neutral",
author = ProfileAuthorDto(id = "t1", login = "petrov"), author = ProfileAuthorDto(id = "t1", name = "Петров Пётр"),
createdAt = "2026-09-29T09:30:00Z", createdAt = "2026-09-29T09:30:00Z",
).apply { createdAtMsk = "29.09.2026 12:30" } ).apply { createdAtMsk = "29.09.2026 12:30" }