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,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **59 claims, 385 тестов, 18/18 browser E2E**.
Baseline: **66 claims, 398 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера)

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,7 @@
| markdown | 5 | 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; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 385 тестов, 0 failures)
Проверено автоматическими тестами (прогон этапа 6 — 385 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
@@ -347,7 +347,7 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline
После этапа 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,
mutation testing и браузерный Playwright E2E. Результат:
**59/59 claims proven, P0 gaps = 0, 385 тестов, 0 failures**; mutation strength

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -359,11 +359,16 @@ class ContentService(
articles.save(a)
}
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> {
fun listAll(
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val all =
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
@@ -377,6 +382,8 @@ class ContentService(
bodyMd = a.bodyMd,
)
}
if (limit <= 0) return all
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
}
@Transactional
@@ -385,6 +392,10 @@ class ContentService(
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,10 @@ class GroupController(
private val auditService: AuditService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "0") limit: Int,
): List<GroupSummary> = groupService.list(page, limit)
@PostMapping
fun create(
@@ -148,6 +151,29 @@ class GroupController(
return detail
}
/** Возврат группы из архива. */
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail {
val detail = groupService.restoreGroup(id)
auditService.record(
jwt.toAuditRequest(
action = "GROUP_RESTORE",
entityType = "GROUP",
entityId = id.toString(),
details = "name=${detail.name}",
),
)
return detail
}
@GetMapping("/{id}/history")
fun history(
@PathVariable id: UUID,
): List<MembershipHistoryRow> = groupService.history(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.springframework.data.domain.PageRequest
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -34,8 +35,22 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val cohortResolver: GroupCohortResolver,
) {
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
fun list(
page: Int = 0,
limit: Int = 0,
): List<GroupSummary> {
val rows =
if (limit <= 0) {
groups.findAllByOrderByEnrollmentYearDescNameAsc()
} else {
groups.findAllByOrderByEnrollmentYearDescNameAsc(
PageRequest.of(page.coerceAtLeast(0), limit.coerceIn(1, MAX_GROUP_PAGE)),
)
}
return rows.map { summary(it) }
}
@Transactional
fun createStudent(
@@ -262,6 +277,43 @@ class GroupService(
return detail(groupId)
}
/** Последние события членства, связанные с группой (вход или выход). */
@Transactional(readOnly = true)
fun history(groupId: UUID): List<MembershipHistoryRow> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val events = history.findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(groupId, groupId)
val studentIds = events.map { it.studentId }.distinct()
val studentsById = students.findAllById(studentIds).associateBy { it.accountId }
val groupIds = events.flatMap { listOfNotNull(it.fromGroupId, it.toGroupId) }.distinct()
val groupsById = groups.findAllById(groupIds).associateBy { it.id }
return events.map { event ->
MembershipHistoryRow(
id = event.id.toString(),
studentId = event.studentId.toString(),
studentName = studentsById[event.studentId]?.fullName.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: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
@@ -350,6 +402,10 @@ class GroupService(
}
}
private companion object {
const val MAX_GROUP_PAGE = 500
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {

View File

@@ -85,6 +85,20 @@ data class ResetPasswordResponse(
val password: String,
)
/** Событие истории членства для страницы группы. */
data class MembershipHistoryRow(
val id: String,
val studentId: String,
val studentName: String,
val fromGroupId: String?,
val fromGroupName: String?,
val toGroupId: String?,
val toGroupName: String?,
val action: String,
val reason: String,
val at: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
@@ -13,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@@ -26,6 +28,7 @@ import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.io.ByteArrayOutputStream
import java.util.UUID
@SpringBootTest
@@ -89,6 +92,40 @@ class GroupServiceIntegrationTest {
assertTrue(error is ConflictException)
}
@Test
fun `creates two distinct cohorts in the same stream`() {
val first = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
assertEquals("ТЕСТ-11БО", first.name)
assertEquals("ТЕСТ-12БО", second.name)
}
@Test
fun `lists groups page by page and returns all without limit`() {
val stream = "P" + UUID.randomUUID().toString().take(8)
repeat(3) { index ->
groupService.create(
CreateGroupRequest(
stream = stream,
course = 1,
number = (index + 1).toShort(),
degree = "БО",
enrollmentYear = 2025,
),
)
}
val first = groupService.list(page = 0, limit = 2).filter { it.name.startsWith(stream) }
val second = groupService.list(page = 1, limit = 2).filter { it.name.startsWith(stream) }
val all = groupService.list().filter { it.name.startsWith(stream) }
assertEquals(2, first.size)
assertEquals(1, second.size)
assertEquals(3, all.size)
assertTrue(first.intersect(second.toSet()).isEmpty())
}
@Test
fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null)
@@ -106,6 +143,11 @@ class GroupServiceIntegrationTest {
assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action)
val groupHistory = groupService.history(groupId)
val imported = groupHistory.first { it.studentId == studentId.toString() }
assertEquals("IMPORT", imported.action)
assertEquals("Иванов Иван", imported.studentName)
assertEquals("ПИ-21БО", imported.toGroupName)
}
@Test
@@ -272,6 +314,64 @@ class GroupServiceIntegrationTest {
assertNull(students.findByStudentNo("3001"))
}
@Test
fun `restores archived group and rejects repeated restore`() {
val created = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 7, degree = "БО", enrollmentYear = 2026))
val id = UUID.fromString(created.id)
assertEquals("ARCHIVED", groupService.archiveGroup(id).status)
assertEquals("ACTIVE", groupService.restoreGroup(id).status)
val error = runCatching { groupService.restoreGroup(id) }.exceptionOrNull()
assertTrue(error is BadRequestException)
}
@Test
fun `xlsx import reports occupied login before writing`() {
val studentNo = "X" + UUID.randomUUID().toString().take(8)
accounts.save(
Account(
login = studentNo,
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result = importService.import("group.xlsx", xlsx("ПИ;1;4;БО;2026;Иванов Иван;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("Логин $studentNo"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `xlsx import reports inactive target group before writing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 5, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val studentNo = "Y" + UUID.randomUUID().toString().take(8)
val result = importService.import("group.xlsx", xlsx("ПИ;1;5;БО;2026;Петров Пётр;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("не активна"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
@@ -324,4 +424,22 @@ class GroupServiceIntegrationTest {
""".trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
private fun xlsx(row: String): ByteArray {
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
val values = row.split(";")
return XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("group")
sheet.createRow(0).apply {
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
}
sheet.createRow(1).apply {
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
}
ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.progress
import jakarta.persistence.EntityManagerFactory
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.`when`
@@ -31,7 +33,12 @@ import java.util.UUID
* Проверяются доступ, публикация оценок, все попытки сдач и скачивание.
*/
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-profile-submissions"])
@SpringBootTest(
properties = [
"lms.submissions.dir=build/test-profile-submissions",
"spring.jpa.properties.hibernate.generate_statistics=true",
],
)
class StudentProfileIntegrationTest {
companion object {
@Container
@@ -65,6 +72,9 @@ class StudentProfileIntegrationTest {
@Autowired
private lateinit var storage: SubmissionStorage
@Autowired
private lateinit var entityManagerFactory: EntityManagerFactory
@MockitoBean
private lateinit var academic: AcademicClient
@@ -72,6 +82,25 @@ class StudentProfileIntegrationTest {
private lateinit var identity: IdentityClient
@Test
fun `profile uses one query per data kind instead of per lab`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labs = (1..10).map { labInfo(UUID.randomUUID(), it, UUID.randomUUID(), UUID.randomUUID()) }
stubCourse(courseId, groupId, TEACHER_TOKEN, labs)
stubStudent(studentId, groupId)
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(profile.labs).hasSize(10)
assertThat(statistics.prepareStatementCount).isLessThanOrEqualTo(15)
}
@Test
@Suppress("LongMethod")
fun `teacher sees unpublished grade, authors and all submission attempts`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
@@ -111,9 +140,13 @@ class StudentProfileIntegrationTest {
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
val lab = profile.labs.single()
assertThat(lab.variant?.id).isEqualTo(variantId.toString())
assertThat(lab.variant?.number).isEqualTo("7")
assertThat(lab.level?.id).isEqualTo(levelId.toString())
assertThat(lab.level?.code).isEqualTo("base")
assertThat(lab.level?.title).isEqualTo("Базовый")
assertThat(lab.attemptsCount).isEqualTo(2)
assertThat(lab.submissionsTotal).isEqualTo(2)
assertThat(lab.submissions.map { it.seq }).containsExactly(2, 1)
assertThat(lab.submissions.first().files).containsExactlyInAnyOrder("main.c", "README.md")
assertThat(lab.submissions.first().downloadUrl)
@@ -121,10 +154,10 @@ class StudentProfileIntegrationTest {
val grade = checkNotNull(lab.grade)
assertThat(grade.score).isEqualTo("4.5")
assertThat(grade.published).isFalse()
assertThat(grade.author?.login).isEqualTo("teacher_login")
assertThat(grade.updatedBy?.name).isEqualTo("teacher_login")
assertThat(lab.comments).hasSize(1)
val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login")
assertThat(commentAuthor?.name).isEqualTo("teacher_login")
val download =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
@@ -236,6 +269,27 @@ class StudentProfileIntegrationTest {
assertThat(download.filename).endsWith(".zip")
}
@Test
fun `profile limits submission attempts but reports total`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labId = UUID.randomUUID()
stubCourse(courseId, groupId, TEACHER_TOKEN, listOf(labInfo(labId, 1, UUID.randomUUID(), UUID.randomUUID())))
stubStudent(studentId, groupId)
saveSubmission(studentId, labId, 1, "2026-10-01T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 2, "2026-10-02T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 3, "2026-10-03T10:00:00Z", "main.c")
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", TEACHER_TOKEN, submissionLimit = 1)
val lab = profile.labs.single()
assertThat(lab.submissionsTotal).isEqualTo(3)
assertThat(lab.attemptsCount).isEqualTo(3)
assertThat(lab.submissions.map { it.seq }).containsExactly(3)
}
private fun stubCourse(
courseId: 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.ApiException
import ru.ysu.lms.web.api.archiveGroup
import ru.ysu.lms.web.api.restoreGroup
import tools.jackson.databind.ObjectMapper
/** Архив группы без массового изменения статусов студентов. */
@@ -20,6 +21,21 @@ class AdminGroupArchiveController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.restoreGroup(token(session), id)
redirect.addFlashAttribute("notice", "Группа возвращена из архива")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: String,

View File

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

View File

@@ -35,10 +35,25 @@ fun ApiClient.contentArticle(
.body(ContentArticleDto::class.java)
}
fun ApiClient.contentTreeList(token: String?): List<ContentArticleDto> {
fun ApiClient.contentTreeList(
token: String?,
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call {
val spec = client.get().uri("/api/v1/content/articles")
val spec =
client
.get()
.uri { builder ->
builder.path("/api/v1/content/articles")
if (limit > 0) {
builder
.queryParam("offset", offset.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve()
.body(type)

View File

@@ -8,13 +8,24 @@ import org.springframework.util.LinkedMultiValueMap
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
fun ApiClient.listGroups(token: String): List<GroupDto> {
fun ApiClient.listGroups(
token: String,
page: Int = 0,
limit: Int = 0,
): List<GroupDto> {
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder.path("/api/v1/groups")
if (limit > 0) {
builder
.queryParam("page", page.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
@@ -165,6 +176,34 @@ fun ApiClient.archiveGroup(
.body(GroupDetailDto::class.java)
}
fun ApiClient.groupHistory(
token: String,
groupId: String,
): List<MembershipHistoryDto> {
val type = object : ParameterizedTypeReference<List<MembershipHistoryDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups/{id}/history", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.restoreGroup(
token: String,
groupId: String,
): GroupDetailDto =
call {
client
.post()
.uri("/api/v1/groups/{id}/restore", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(GroupDetailDto::class.java)
}
fun ApiClient.credentials(
token: String,
groupId: String,

View File

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

View File

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

View File

@@ -32,6 +32,11 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">В архив</button>
</form>
<form class="inline-form" th:if="${group.status == 'ARCHIVED'}"
th:action="@{/admin/groups/{id}/restore(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn" type="submit">Вернуть из архива</button>
</form>
</div>
</div>
@@ -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}">
<div class="dlg-header">

View File

@@ -56,6 +56,15 @@
</table>
</div>
<div class="flex items-center justify-between gap-4 mt-4"
th:if="${(page ?: 0) > 0 or (hasNext ?: false)}">
<a class="btn btn-secondary" th:if="${(page ?: 0) > 0}"
th:href="@{/admin/groups(page=${(page ?: 0) - 1})}">← Назад</a>
<span class="text-sm text-muted">Страница <b th:text="${(page ?: 0) + 1}">1</b></span>
<a class="btn btn-secondary" th:if="${hasNext ?: false}"
th:href="@{/admin/groups(page=${(page ?: 0) + 1})}">Далее →</a>
</div>
<dialog id="modal-create-group" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая группа</h2>

View File

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

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.ImportResultDto
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.PromotionRunDetailDto
import ru.ysu.lms.web.api.PromotionRunSummaryDto
@@ -215,6 +216,55 @@ class AdminStage4TemplateTest {
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
fun `group import form requires explicit restoration flag`() {
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))

View File

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

View File

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