From 82394cc68801ed5db4aa14349df9aa46d40c53de Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 29 Oct 2026 20:51:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20P1/P2=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8E,=20=D0=BA=D0=BE=D0=B3=D0=BE=D1=80=D1=82=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток - V14: NOT NULL и полная уникальность когорты, preflight legacy-групп - возврат группы из архива и история членства на странице группы - пагинация групп и плоского списка материалов - пакетные сдачи ведомости одним запросом - XLSX-preflight импорта и audit-тесты ростера - LDD: 66 claims, 398 тестов, release PASS --- README.md | 2 +- deploy/DEPLOY.md | 20 ++- docs/08-groups-courses-plan.md | 27 ++-- docs/09-teacher-student-profile.md | 12 +- docs/16-stage6-report.md | 6 +- docs/17-handoff.md | 44 ++++--- docs/18-goal-audit.md | 84 ++++++++----- docs/README.md | 2 +- docs/evidence/README.md | 8 +- docs/evidence/baseline-2026-10-29.md | 44 +++++++ docs/evidence/claims.json | 108 +++++++++++++++- scripts/ldd-migration-upgrade.sh | 29 ++++- .../lms/academic/content/ContentService.kt | 41 +++--- .../controller/ContentAdminController.kt | 5 +- .../content/CourseContentIntegrationTest.kt | 15 +++ .../ru/ysu/lms/identity/domain/GroupEntity.kt | 8 +- .../ysu/lms/identity/internal/InternalDtos.kt | 2 + .../lms/identity/internal/InternalService.kt | 14 ++- .../ysu/lms/identity/repo/GroupRepository.kt | 3 + .../repo/MembershipHistoryRepository.kt | 5 + .../lms/identity/roster/GroupController.kt | 28 ++++- .../ysu/lms/identity/roster/GroupService.kt | 58 ++++++++- .../ru/ysu/lms/identity/roster/RosterDtos.kt | 14 +++ .../db/migration/V14__cohort_constraints.sql | 43 +++++++ .../identity/audit/AuditIntegrationTest.kt | 83 ++++++++++++ .../InternalControllerIntegrationTest.kt | 57 ++++++++- .../roster/GroupServiceIntegrationTest.kt | 118 ++++++++++++++++++ .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 1 + .../ysu/lms/progress/GroupLabDataService.kt | 8 +- .../ru/ysu/lms/progress/SubmissionService.kt | 13 ++ .../lms/progress/profile/ProfileController.kt | 3 + .../ysu/lms/progress/profile/ProfileDtos.kt | 10 +- .../progress/profile/StudentProfileService.kt | 43 ++++--- .../lms/progress/repo/SubmissionRepository.kt | 2 + .../progress/StudentProfileIntegrationTest.kt | 60 ++++++++- .../SubmissionBatchIntegrationTest.kt | 63 ++++++++++ .../web/admin/AdminGroupArchiveController.kt | 16 +++ .../ysu/lms/web/admin/AdminGroupController.kt | 26 +++- .../ru/ysu/lms/web/api/ApiClientContent.kt | 19 ++- .../ru/ysu/lms/web/api/ApiClientIdentity.kt | 45 ++++++- .../ru/ysu/lms/web/api/ApiClientProfile.kt | 9 +- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 24 +++- .../main/resources/templates/admin/group.html | 35 ++++++ .../resources/templates/admin/groups.html | 9 ++ .../resources/templates/profile/student.html | 9 +- .../lms/web/admin/AdminStage4TemplateTest.kt | 50 ++++++++ .../ysu/lms/web/profile/ProfileDtoJsonTest.kt | 18 ++- .../web/profile/StudentProfileTemplateTest.kt | 9 +- 48 files changed, 1196 insertions(+), 156 deletions(-) create mode 100644 docs/evidence/baseline-2026-10-29.md create mode 100644 services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql create mode 100644 services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionBatchIntegrationTest.kt diff --git a/README.md b/README.md index 539a4fc..b5be8d8 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). - Baseline: **59 claims, 385 тестов, 18/18 browser E2E**. + Baseline: **66 claims, 398 тестов, 18/18 browser E2E**. ## Запуск в Docker (для сервера) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 4f2f62e..822e8bc 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -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`); преподаватель и diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index 9ffa2ab..e9466c7 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -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` обязателен для тегов. diff --git a/docs/09-teacher-student-profile.md b/docs/09-teacher-student-profile.md index 87422b8..d5acb66 100644 --- a/docs/09-teacher-student-profile.md +++ b/docs/09-teacher-student-profile.md @@ -151,7 +151,9 @@ Authorization: Bearer `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-запросов в интеграционном тесте). diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md index 133369f..28b6e46 100644 --- a/docs/16-stage6-report.md +++ b/docs/16-stage6-report.md @@ -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 diff --git a/docs/17-handoff.md b/docs/17-handoff.md index aa35560..daa394c 100644 --- a/docs/17-handoff.md +++ b/docs/17-handoff.md @@ -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`. diff --git a/docs/18-goal-audit.md b/docs/18-goal-audit.md index 785e717..77168fb 100644 --- a/docs/18-goal-audit.md +++ b/docs/18-goal-audit.md @@ -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`. diff --git a/docs/README.md b/docs/README.md index 72936c8..6ab2125 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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, миграции, доступ, статусы, повышение | ## Кратко о системе diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 9b662b5..b05ab86 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -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`. diff --git a/docs/evidence/baseline-2026-10-29.md b/docs/evidence/baseline-2026-10-29.md new file mode 100644 index 0000000..2647c5f --- /dev/null +++ b/docs/evidence/baseline-2026-10-29.md @@ -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`. diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index 1dae1fe..ca416e0 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -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 событий; фильтра/пагинации по истории нет." } ] } \ No newline at end of file diff --git a/scripts/ldd-migration-upgrade.sh b/scripts/ldd-migration-upgrade.sh index f4fbf22..5d38e84 100755 --- a/scripts/ldd-migration-upgrade.sh +++ b/scripts/ldd-migration-upgrade.sh @@ -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() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index c26cbad..992eaac 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -359,24 +359,31 @@ class ContentService( articles.save(a) } + /** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */ @Transactional(readOnly = true) - fun listAll(): List { + fun listAll( + offset: Int = 0, + limit: Int = 0, + ): List { val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id } val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id } - return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a -> - val sec = secById[a.sectionId] ?: return@mapNotNull null - val ch = chById[sec.chapterId] ?: return@mapNotNull null - ContentArticleView( - id = a.id.toString(), - chapterSlug = ch.slug, - chapterTitle = ch.title, - sectionSlug = sec.slug, - sectionTitle = sec.title, - slug = a.slug, - title = a.title, - bodyMd = a.bodyMd, - ) - } + val all = + articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a -> + val sec = secById[a.sectionId] ?: return@mapNotNull null + val ch = chById[sec.chapterId] ?: return@mapNotNull null + ContentArticleView( + id = a.id.toString(), + chapterSlug = ch.slug, + chapterTitle = ch.title, + sectionSlug = sec.slug, + sectionTitle = sec.title, + slug = a.slug, + title = a.title, + bodyMd = a.bodyMd, + ) + } + if (limit <= 0) return all + return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE)) } @Transactional @@ -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 + } } /** diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index 60f7031..93f9555 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -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( diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt index 08adf85..ab59b8f 100644 --- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt @@ -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") diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt index cc722fa..13b36e0 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt @@ -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) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt index 8d5b55a..e0da7c8 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt @@ -21,6 +21,8 @@ data class AccountInfoDto( val id: String, val login: String, val role: String, + /** ФИО студента; для не-студенческих аккаунтов пусто. */ + val fullName: String = "", ) /** Группа с текущим составом — без учётных данных. */ diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt index 4271d2f..73c167f 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt @@ -76,15 +76,25 @@ class InternalService( ) } - /** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */ + /** Подписи авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */ @Transactional(readOnly = true) fun lookupAccounts(ids: List): List { 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(), + ) } } } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt index 974be05..103b28e 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt @@ -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 { fun findAllByOrderByNameAsc(): List fun findAllByOrderByEnrollmentYearDescNameAsc(): List + + fun findAllByOrderByEnrollmentYearDescNameAsc(pageable: Pageable): List } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt index 7449bbc..beb4b1e 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt @@ -6,4 +6,9 @@ import java.util.UUID interface MembershipHistoryRepository : JpaRepository { fun findByStudentIdOrderByAtDesc(studentId: UUID): List + + fun findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc( + toGroupId: UUID, + fromGroupId: UUID, + ): List } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt index ed893e7..63ec0dc 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt @@ -31,7 +31,10 @@ class GroupController( private val auditService: AuditService, ) { @GetMapping - fun list(): List = groupService.list() + fun list( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "0") limit: Int, + ): List = 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 = groupService.history(id) + @GetMapping("/{id}/credentials") fun credentials( @PathVariable id: UUID, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index 2862587..ea1669c 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -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 = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) } + fun list( + page: Int = 0, + limit: Int = 0, + ): List { + 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 { + 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) { diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt index 911e5cb..0f409b2 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt @@ -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, diff --git a/services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql b/services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql new file mode 100644 index 0000000..9aa43fa --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql @@ -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); diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt index ba06ea0..ebcc5b2 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt @@ -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, val cursor: String?, diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt index 6e3b217..60b8624 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt @@ -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, diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt index bb8f56f..0f35a57 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt @@ -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() + } + } + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index b4c0e8b..bba4cd7 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -122,6 +122,7 @@ data class AccountInfo( val id: String = "", val login: String = "", val role: String = "", + val fullName: String = "", ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataService.kt index 445688c..045ee98 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataService.kt @@ -20,8 +20,9 @@ class GroupLabDataService( groupId: UUID, labIds: List, 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), ) }, ) + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt index 09993cc..78fde8d 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionService.kt @@ -159,6 +159,19 @@ class SubmissionService( return perStudent.values.map { toDto(it, filesOf(it)) } } + /** Последняя сдача каждого студента по нескольким лабораторным — одним запросом. */ + @Transactional(readOnly = true) + fun latestPerStudentByLab(labIds: Collection): Map> { + if (labIds.isEmpty()) return emptyMap() + val perLabAndStudent = mutableMapOf, 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") diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt index eb9e66f..8cae00d 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt @@ -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 на горячем пути и не засорять журнал. diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt index c2fd70a..d7dfe8a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt @@ -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, val defense: ProfileDefense?, + /** Всего попыток; [submissions] может быть ограничен для больших курсов. */ + val submissionsTotal: Int = 0, val submissions: List, ) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt index e3269d2..d93446d 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt @@ -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): Map { + private fun lookupAuthors(authorIds: Set): Map { 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, + authors: Map, + 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 { it.createdAt }.thenBy { it.seq })?.createdAt?.toString() + allSubmissions.maxWithOrNull(compareBy { 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, - ): ProfileAuthor? = - authorId?.let { - val key = it.toString() - ProfileAuthor(key, logins[key] ?: UNKNOWN_AUTHOR) - } + authors: Map, + ): 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 } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt index 8808e14..050388e 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt @@ -19,6 +19,8 @@ interface SubmissionRepository : JpaRepository { fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List + fun findByLabIdInOrderByCreatedAtDesc(labIds: Collection): List + fun findFirstByStudentIdAndLabIdOrderBySeqDesc( studentId: UUID, labId: UUID, diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt index 14d3ea3..846adc9 100644 --- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt @@ -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, diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionBatchIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionBatchIntegrationTest.kt new file mode 100644 index 0000000..8f352bb --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/SubmissionBatchIntegrationTest.kt @@ -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)) +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt index f4cb541..b74db0e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt @@ -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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 164d108..919e4b2 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -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, attestations: List, candidates: List, + history: List, ): 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, val submissionCounts: Map, val attestations: List, + val history: List, ) 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 }) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt index 413366c..1fa4a2c 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt @@ -35,10 +35,25 @@ fun ApiClient.contentArticle( .body(ContentArticleDto::class.java) } -fun ApiClient.contentTreeList(token: String?): List { +fun ApiClient.contentTreeList( + token: String?, + offset: Int = 0, + limit: Int = 0, +): List { val type = object : ParameterizedTypeReference>() {} 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) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt index abe13d1..4357f05 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt @@ -8,13 +8,24 @@ import org.springframework.util.LinkedMultiValueMap import java.net.URLDecoder import java.nio.charset.StandardCharsets -fun ApiClient.listGroups(token: String): List { +fun ApiClient.listGroups( + token: String, + page: Int = 0, + limit: Int = 0, +): List { val type = object : ParameterizedTypeReference>() {} 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 { + val type = object : ParameterizedTypeReference>() {} + 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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt index d6a2643..b8e5910 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProfile.kt @@ -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) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 1f170ad..4e252d1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -132,6 +132,22 @@ data class GroupDetailDto( val students: List = 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 = emptyList(), val defense: ProfileDefenseDto? = null, + val submissionsTotal: Int = 0, val submissions: List = 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) diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 8d15107..bc434cf 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -32,6 +32,11 @@ +
+ + +
@@ -402,6 +407,36 @@ +
+
+

История членства

+

Последние события по группе: зачисление, перевод, импорт, академ/возврат, повышение и архив.

+
+ + + + + + + + + + + + + +
Когда (МСК)СтудентСобытиеОткуда → кудаПричина
—— + — + + — + → + — + —
+
+
+
+
diff --git a/web/src/main/resources/templates/admin/groups.html b/web/src/main/resources/templates/admin/groups.html index d8cc1d3..8491cc6 100644 --- a/web/src/main/resources/templates/admin/groups.html +++ b/web/src/main/resources/templates/admin/groups.html @@ -56,6 +56,15 @@
+
+ ← Назад + Страница 1 + Далее → +
+

Новая группа

diff --git a/web/src/main/resources/templates/profile/student.html b/web/src/main/resources/templates/profile/student.html index d8c46d8..cc1743e 100644 --- a/web/src/main/resources/templates/profile/student.html +++ b/web/src/main/resources/templates/profile/student.html @@ -129,8 +129,8 @@

Оценка

4.5 - +

Оценка ещё не выставлена.

@@ -169,7 +169,7 @@ th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный — + th:text="|— ${comment.author.name}|"> — автор не указан @@ -178,6 +178,9 @@

Попытки сдачи

Сдач по этой лабораторной нет.

+

Показаны последние попытки.

diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt index 5da19b1..9973dd8 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminStage4TemplateTest.kt @@ -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( + "group" to group, + "labs" to emptyList(), + "candidates" to emptyList(), + "studentGrades" to emptyList(), + "labPublication" to emptyMap(), + "labSubmissionCounts" to emptyMap(), + "attestations" to emptyList(), + "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())) diff --git a/web/src/test/kotlin/ru/ysu/lms/web/profile/ProfileDtoJsonTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/profile/ProfileDtoJsonTest.kt index 8ba1742..ca0b455 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/profile/ProfileDtoJsonTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/profile/ProfileDtoJsonTest.kt @@ -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 diff --git a/web/src/test/kotlin/ru/ysu/lms/web/profile/StudentProfileTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/profile/StudentProfileTemplateTest.kt index 26f4542..6724ee0 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/profile/StudentProfileTemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/profile/StudentProfileTemplateTest.kt @@ -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" }