feat(web): перевести браузерные URL курсов на courseNo

- teacher/admin course pages, content, gradebook, profile, downloads
- courseNo -> UUID только на границе REST API
- обновлены e2e, шаблоны, тесты и docs
- LDD-UI-002; baseline 59 claims / 385 тестов
This commit is contained in:
2026-10-29 19:51:03 +03:00
parent b57a5ffbce
commit 65ed08f5c2
45 changed files with 1058 additions and 352 deletions

View File

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

View File

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

View File

@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events` ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**368**, 0 failures), upgrade миграций с master-схемы тестов/линтеров (**385**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица, (identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **58 claims gateway, видимость активов и deploy-runbook. LDD baseline: **59 claims
(27 P0 / 31 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: (27 P0 / 32 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md). [16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6 ### 9.1. Известные пробелы после этапа 6

View File

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

View File

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

View File

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

View File

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

View File

@@ -116,8 +116,8 @@
### 1.8. LDD и проверки ### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`. - Реестр: `docs/evidence/claims.json`.
- **58 claims**: 27 P0 + 31 P1; **58/58 proven, P0 gaps = 0**. - **59 claims**: 27 P0 + 32 P1; **59/59 proven, P0 gaps = 0**.
- **368 автотестов**, 0 failures/errors/skipped. - **385 автотестов**, 0 failures/errors/skipped.
- Mutation testing: - Mutation testing:
- `CourseAccessService` — 75% test strength; - `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%. - `PromotionPlanner` — 69%.
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
``` ```
Последний подтверждённый результат: Последний подтверждённый результат:
- `368` тестов, 0 failures; - `385` тестов, 0 failures;
- `58/58` claims, P0 gaps = 0; - `59/59` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS; - `ldd-release.sh` — PASS;
- smoke — PASS; - smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit); - browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
`feature/groups-courses`. `feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`. `docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 368 тестов 3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 385 тестов
и 58 claims, 58/58 proven. и 59 claims, 59/59 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`. 4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и

View File

@@ -20,13 +20,13 @@
| Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями | | Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями |
| Курсы, преподаватели, группы курса | implemented | — | | Курсы, преподаватели, группы курса | implemented | — |
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` | | Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
| Профиль студента | partial | Браузерный URL использует UUID вместо `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» | | Профиль студента | partial | URL уже `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» |
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта | | Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
| Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет | | Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет |
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение | | Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён | | CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе | | Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе |
| LDD | implemented | 58 claims (27 P0 / 31 P1), 58/58 proven после release; `check` доказывает P0/unit, runtime P1 — release | | LDD | implemented | 59 claims (27 P0 / 32 P1), 59/59 proven после release; `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена | | Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 | | «Контингент» | out of scope | Исключён решением 2026-10-28 |
@@ -79,7 +79,11 @@
- Старые комментарии без автора показывают «автор не указан». - Старые комментарии без автора показывают «автор не указан».
- Студенческий профиль не показывает черновики лабораторных. - Студенческий профиль не показывает черновики лабораторных.
10. **Импорт: ошибки до записи.** 10. **Браузерные URL курсов — `courseNo`.**
Страницы преподавателя, админа, материалы и профиль студента принимают
человеческий номер курса; REST API продолжает работать с UUID.
11. **Импорт: ошибки до записи.**
- Занятый логин и неактивная целевая группа возвращают `applied=false` - Занятый логин и неактивная целевая группа возвращают `applied=false`
с номером строки и не создают частичных записей. с номером строки и не создают частичных записей.
- Тесты: `GroupServiceIntegrationTest#import reports occupied login...`, - Тесты: `GroupServiceIntegrationTest#import reports occupied login...`,
@@ -89,9 +93,9 @@
### P1 ### P1
- **Профиль: контракт.** Браузерные маршруты используют UUID курса, а docs/09 - **Профиль: поля контракта.** URL уже используют `courseNo`, но часть полей
обещает `courseNo`; часть полей `variant/level/grade` отличается от контракта. `variant/level/grade` и авторы комментариев отличаются от docs/09; нужен
Нужно либо привести код к контракту, либо официально обновить контракт. либо код к контракту, либо официальное обновление контракта.
- **Backup restore на staging** не подтверждён артефактом/логом. - **Backup restore на staging** не подтверждён артефактом/логом.
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён. - **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
@@ -138,9 +142,9 @@
## 5. LDD ## 5. LDD
- Реестр: `docs/evidence/claims.json`, **58 claims** (27 P0 / 31 P1). - Реестр: `docs/evidence/claims.json`, **59 claims** (27 P0 / 32 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0. - `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **58/58 claims**, 368 тестов, mutation - `./scripts/ldd-release.sh` — PASS: **59/59 claims**, 385 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18, `CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с. runtime/load p95 < 1 с.

View File

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

View File

@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков: рисков:
- **368 автоматических тестов**, 0 failures/errors; - **385 автоматических тестов**, 0 failures/errors;
- **58 claims**: 27 P0 + 31 P1, **58/58 proven, P0 gaps = 0**. P0 — доступы, - **59 claims**: 27 P0 + 32 P1, **59/59 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит, (включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,

View File

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

View File

@@ -854,6 +854,24 @@
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()" "ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()"
], ],
"limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта." "limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта."
},
{
"id": "LDD-UI-002",
"title": "Браузерные URL курсов используют courseNo",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.2; docs/09 §8",
"claim": "Страницы преподавателя, админа, материалы и профиль строят URL по courseNo, а REST API вызывают с UUID курса.",
"oracle": "Ссылки/redirects шаблонов и контроллеров содержат courseNo, API получает UUID",
"evidence": [
"ru.ysu.lms.web.teacher.TeacherCourseTemplateTest#course links use human course number instead of uuid()",
"ru.ysu.lms.web.teacher.TeacherCourseContentControllerTest#content page resolves course by human number and keeps uuid for api()",
"ru.ysu.lms.web.admin.AdminCourseControllerTest#detail page resolves course by number and calls api with uuid()",
"ru.ysu.lms.web.admin.AdminCourseTemplateTest#course page forms and actions use human number()",
"ru.ysu.lms.web.admin.AdminGroupControllerTest#teacher gradebook resolves course number and scopes api calls by uuid()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()"
],
"limitations": "Внутренние REST-контракты остаются UUID; form payload создания/копирования лабораторной тоже использует UUID."
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CreateGroupPayload import ru.ysu.lms.web.api.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeDto import ru.ysu.lms.web.api.GradeDto
@@ -32,7 +33,7 @@ import ru.ysu.lms.web.api.StudentCandidateDto
import ru.ysu.lms.web.api.StudentRowDto import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.SubmissionDto import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.createGroup import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate import ru.ysu.lms.web.api.deleteCommentTemplate
@@ -163,25 +164,34 @@ class AdminGroupController(
@GetMapping("/groups/{id}") @GetMapping("/groups/{id}")
fun groupDetail( fun groupDetail(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
val t = token(session) val t = token(session)
val teacher = isTeacher(session) val teacher = isTeacher(session)
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } val course = resolveCourse(t, courseNo)
val view = val view =
if (teacher) { if (teacher) {
teacherGroupView(t, id, effectiveCourseId) teacherGroupView(t, id, course)
} else { } else {
adminGroupView(t, id) adminGroupView(t, id)
} }
fillGroupModel(model, view) fillGroupModel(model, view)
model.addAttribute("teacherView", teacher) model.addAttribute("teacherView", teacher)
model.addAttribute("profileCourseId", effectiveCourseId) model.addAttribute("profileCourseNo", course?.courseNo)
return "admin/group" return "admin/group"
} }
private fun resolveCourse(
token: String,
rawCourseNo: String?,
): CourseDetailDto? =
rawCourseNo
?.trim()
?.takeIf { it.isNotBlank() }
?.let { api.courseByUrlNo(token, it) }
/** Администратор видит все группы и аттестации; преподаватель — только свой курс. */ /** Администратор видит все группы и аттестации; преподаватель — только свой курс. */
private fun adminGroupView( private fun adminGroupView(
token: String, token: String,
@@ -203,11 +213,10 @@ class AdminGroupController(
private fun teacherGroupView( private fun teacherGroupView(
token: String, token: String,
id: String, id: String,
courseId: String?, course: CourseDetailDto?,
): GroupViewModel { ): GroupViewModel {
if (courseId == null) throw ApiException(400, "Не указан курс") if (course == null) throw ApiException(400, "Не указан курс")
api.courseDetail(token, courseId) val roster = api.courseGroupStudents(token, course.id, id)
val roster = api.courseGroupStudents(token, courseId, id)
val group = val group =
GroupDetailDto( GroupDetailDto(
id = id, id = id,
@@ -222,10 +231,10 @@ class AdminGroupController(
) )
}, },
) )
val labs = api.listLabs(token).filter { it.courseId == courseId && it.status == "published" } val labs = api.listLabs(token).filter { it.courseId == course.id && it.status == "published" }
val labIds = labs.map { it.id }.toSet() val labIds = labs.map { it.id }.toSet()
val memberIds = group.students.map { it.id }.toSet() val memberIds = group.students.map { it.id }.toSet()
val grades = api.listGrades(token, courseId).filter { it.studentId in memberIds && it.labId in labIds } val grades = api.listGrades(token, course.id).filter { it.studentId in memberIds && it.labId in labIds }
val dataByLab = val dataByLab =
if (labs.isEmpty()) { if (labs.isEmpty()) {
emptyMap() emptyMap()
@@ -414,13 +423,14 @@ class AdminGroupController(
fun assignModal( fun assignModal(
@PathVariable id: String, @PathVariable id: String,
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
val t = token(session) val t = token(session)
val teacher = isTeacher(session) val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher) val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val lab = api.labDetail(t, labId) val lab = api.labDetail(t, labId)
val levels = api.getLevels(t, labId).associateBy { it.studentId } val levels = api.getLevels(t, labId).associateBy { it.studentId }
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId } val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
@@ -446,12 +456,7 @@ class AdminGroupController(
model.addAttribute("groupId", id) model.addAttribute("groupId", id)
model.addAttribute("rows", rows) model.addAttribute("rows", rows)
model.addAttribute("variantUsage", variantUsage) model.addAttribute("variantUsage", variantUsage)
val courseQuery = val courseQuery = course?.let { "&courseNo=${it.courseNo}" }.orEmpty()
courseId
?.trim()
?.takeIf { it.isNotBlank() }
?.let { "&courseId=$it" }
.orEmpty()
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery") model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery")
return "admin/assign-fragment" return "admin/assign-fragment"
} }
@@ -462,15 +467,12 @@ class AdminGroupController(
private fun groupStudentsFor( private fun groupStudentsFor(
token: String, token: String,
groupId: String, groupId: String,
courseId: String?, course: CourseDetailDto?,
teacher: Boolean, teacher: Boolean,
): List<StudentRowDto> { ): List<StudentRowDto> {
if (!teacher) return api.groupDetail(token, groupId).students if (!teacher) return api.groupDetail(token, groupId).students
val effectiveCourseId = if (course == null) throw ApiException(400, "Не указан курс")
courseId?.trim()?.takeIf { it.isNotBlank() } return api.courseGroupStudents(token, course.id, groupId).students.map { student ->
?: throw ApiException(400, "Не указан курс")
api.courseDetail(token, effectiveCourseId)
return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student ->
StudentRowDto( StudentRowDto(
id = student.id, id = student.id,
studentNo = student.studentNo, studentNo = student.studentNo,
@@ -496,7 +498,7 @@ class AdminGroupController(
fun setGrade( fun setGrade(
@PathVariable id: String, @PathVariable id: String,
@ModelAttribute form: SetGradeForm, @ModelAttribute form: SetGradeForm,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
@@ -516,10 +518,10 @@ class AdminGroupController(
), ),
) )
redirect.addFlashAttribute("notice", "Оценка сохранена") redirect.addFlashAttribute("notice", "Оценка сохранена")
groupRedirect(id, courseId) groupRedirect(id, courseNo)
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId) groupRedirect(id, courseNo)
} }
/** Уровни и варианты лабораторной для модалки назначения варианта. */ /** Уровни и варианты лабораторной для модалки назначения варианта. */
@@ -578,15 +580,16 @@ class AdminGroupController(
fun downloadGroupSubmissions( fun downloadGroupSubmissions(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) labId: String?, @RequestParam(required = false) labId: String?,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val t = token(session) val t = token(session)
val teacher = isTeacher(session) val teacher = isTeacher(session)
val students = groupStudentsFor(t, id, courseId, teacher) val course = resolveCourse(t, courseNo)
val students = groupStudentsFor(t, id, course, teacher)
val studentById = students.associateBy { it.id } val studentById = students.associateBy { it.id }
val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) } val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == course?.id) }
val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name val groupName = if (teacher) groupNameFor(t, id, course) else api.groupDetail(t, id).name
val bytes = buildGroupArchive(t, groupName, studentById, labs) val bytes = buildGroupArchive(t, groupName, studentById, labs)
return zipAttachment("solutions-$groupName.zip", bytes) return zipAttachment("solutions-$groupName.zip", bytes)
} }
@@ -595,10 +598,10 @@ class AdminGroupController(
private fun groupNameFor( private fun groupNameFor(
token: String, token: String,
groupId: String, groupId: String,
courseId: String?, course: CourseDetailDto?,
): String { ): String {
val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId val effectiveCourse = course ?: return groupId
return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName } return runCatching { api.courseGroupStudents(token, effectiveCourse.id, groupId).groupName }
.getOrDefault("") .getOrDefault("")
.ifBlank { groupId } .ifBlank { groupId }
} }
@@ -610,19 +613,20 @@ class AdminGroupController(
@PathVariable id: String, @PathVariable id: String,
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam published: Boolean, @RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val t = token(session) val t = token(session)
val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id } val course = resolveCourse(t, courseNo)
val all = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList())) api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList()))
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы") redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы")
groupRedirect(id, courseId) groupRedirect(id, courseNo)
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
groupRedirect(id, courseId) groupRedirect(id, courseNo)
} }
/** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */ /** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */
@@ -634,12 +638,13 @@ class AdminGroupController(
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam studentId: String, @RequestParam studentId: String,
@RequestParam published: Boolean, @RequestParam published: Boolean,
@RequestParam(required = false) courseId: String?, @RequestParam(required = false) courseNo: String?,
session: HttpSession, session: HttpSession,
): Map<String, Any> = ): Map<String, Any> =
try { try {
val t = token(session) val t = token(session)
val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id } val course = resolveCourse(t, courseNo)
val memberIds = groupStudentsFor(t, id, course, isTeacher(session)).map { it.id }
val memberIdSet = memberIds.toSet() val memberIdSet = memberIds.toSet()
val state = api.publicationState(t, id, labId) val state = api.publicationState(t, id, labId)
val current = val current =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div> <div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" <input type="hidden" id="pub-url"
th:value="${profileCourseId != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/publish-student(id=${group.id})}"> th:value="${profileCourseNo != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div> <div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x"> <div class="scroll-x">
@@ -60,14 +60,14 @@
<span class="lab-col-title" th:text="${lab.title}">—</span> <span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions"> <div class="lab-head-actions">
<button class="btn btn-secondary btn-sm p-2" type="button" <button class="btn btn-secondary btn-sm p-2" type="button"
th:attr="data-url=${profileCourseId != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign" th:attr="data-url=${profileCourseNo != null} ? @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе"> title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i> <i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button> </button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}" <form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post"> th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input class="js-pub-published" type="hidden" name="published" <input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'"> th:value="${all} ? 'false' : 'true'">
@@ -81,7 +81,7 @@
</button> </button>
</form> </form>
<a class="btn btn-secondary btn-sm p-2" <a class="btn btn-secondary btn-sm p-2"
th:href="${profileCourseId != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseId=${profileCourseId})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}" th:href="${profileCourseNo != null} ? @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/submissions.zip(id=${group.id}, labId=${lab.id})}"
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a> th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
</div> </div>
</div> </div>
@@ -124,10 +124,10 @@
<tbody> <tbody>
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky"> <td class="student-cell matrix-sticky">
<a class="student-name" th:if="${profileCourseId != null}" <a class="student-name" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}" th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}"
th:text="${row.fullName}">—</a> th:text="${row.fullName}">—</a>
<div class="student-name" th:if="${profileCourseId == null}" th:text="${row.fullName}">—</div> <div class="student-name" th:if="${profileCourseNo == null}" th:text="${row.fullName}">—</div>
<div class="student-no-line"> <div class="student-no-line">
<span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'" <span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'" th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
@@ -196,8 +196,8 @@
</td> </td>
<td class="text-right actions-cell"> <td class="text-right actions-cell">
<div class="actions-col"> <div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != null}" <a class="btn btn-secondary btn-sm" th:if="${profileCourseNo != null}"
th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль</a> th:href="${teacherMode} ? @{/teacher/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})} : @{/admin/courses/{courseNo}/students/{studentId}(courseNo=${profileCourseNo}, studentId=${row.studentId})}">Профиль</a>
<form th:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз."> <form th:if="${!teacherMode}" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit" <button class="btn btn-secondary btn-sm" type="submit"
@@ -258,7 +258,7 @@
<div class="dlg-body"> <div class="dlg-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"> <form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="grade-student"> <input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab"> <input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]"> <input type="hidden" name="commentsJson" id="grade-comments" value="[]">
@@ -333,7 +333,7 @@
<form class="inline-form" id="variant-random-form" method="post" <form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}"> th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-random-student"> <input type="hidden" name="studentId" id="variant-random-student">
<input type="hidden" name="labId" id="variant-random-lab"> <input type="hidden" name="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level"> <input type="hidden" name="labLevelId" id="variant-random-level">
@@ -342,7 +342,7 @@
<form class="inline-form" id="variant-pin-form" method="post" <form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}"> th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="studentId" id="variant-pin-student"> <input type="hidden" name="studentId" id="variant-pin-student">
<input type="hidden" name="labId" id="variant-pin-lab"> <input type="hidden" name="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id"> <input type="hidden" name="variantId" id="variant-pin-id">
@@ -360,7 +360,7 @@
<div class="dlg-body"> <div class="dlg-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post"> <form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="defense-student-select">Студент</label> <div><label class="label" for="defense-student-select">Студент</label>
<select class="input" id="defense-student-select" name="studentId" required> <select class="input" id="defense-student-select" name="studentId" required>
@@ -381,14 +381,14 @@
<div id="defense-actions" class="flex flex-wrap gap-2" hidden> <div id="defense-actions" class="flex flex-wrap gap-2" hidden>
<form class="inline-form" id="defense-issue-form" method="post"> <form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-action-lab"> <input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button> <button class="btn btn-secondary" type="submit">Выдать код</button>
</form> </form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?"> <form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="courseId" th:if="${profileCourseId != null}" th:value="${profileCourseId}"> <input type="hidden" name="courseNo" th:if="${profileCourseNo != null}" th:value="${profileCourseNo}">
<input type="hidden" name="labId" id="defense-reset-lab"> <input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button> <button class="btn btn-danger" type="submit">Начать заново</button>

View File

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

View File

@@ -194,7 +194,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary btn-sm"
th:href="@{${downloadBase}(courseId=${profile.course.id}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a> th:href="@{${downloadBase}(courseNo=${profile.course.courseNo}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать</a>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<head><title th:text="${(#strings.isEmpty(roster.groupName) ? groupId : roster.groupName)} + ' — LMS'">Группа</title></head> <head><title th:text="${(#strings.isEmpty(roster.groupName) ? groupId : roster.groupName)} + ' — LMS'">Группа</title></head>
<body> <body>
<div id="content"> <div id="content">
<a th:href="@{/teacher/courses/{id}(id=${courseId})}" class="nav-link">← К курсу</a> <a th:href="@{/teacher/courses/{no}(no=${courseNo})}" class="nav-link">← К курсу</a>
<div class="page-head mb-2"> <div class="page-head mb-2">
<div> <div>
@@ -33,7 +33,7 @@
<tr th:each="student : ${roster.students}"> <tr th:each="student : ${roster.students}">
<td> <td>
<a class="font-medium" <a class="font-medium"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}" th:href="@{/teacher/courses/{no}/students/{studentId}(no=${courseNo}, studentId=${student.id})}"
th:text="${student.fullName}">—</a> th:text="${student.fullName}">—</a>
</td> </td>
<td class="font-mono" th:text="${student.studentNo}">—</td> <td class="font-mono" th:text="${student.studentNo}">—</td>
@@ -44,7 +44,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<a class="btn btn-sm btn-secondary" <a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{courseId}/students/{studentId}(courseId=${courseId}, studentId=${student.id})}">Открыть</a> th:href="@{/teacher/courses/{no}/students/{studentId}(no=${courseNo}, studentId=${student.id})}">Открыть</a>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(roster.students)}"> <tr th:if="${#lists.isEmpty(roster.students)}">

View File

@@ -0,0 +1,190 @@
package ru.ysu.lms.web.admin
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
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.CourseCreatePayload
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.CourseTeacherDto
import ru.ysu.lms.web.api.CourseTeacherPayload
import ru.ysu.lms.web.api.CourseUpdatePayload
import ru.ysu.lms.web.api.GroupDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.TeacherDto
import ru.ysu.lms.web.api.assignCourseTeacher
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseDetail
import ru.ysu.lms.web.api.createCourse
import ru.ysu.lms.web.api.detachCourseGroup
import ru.ysu.lms.web.api.listGroups
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.listTeachers
import ru.ysu.lms.web.api.removeCourseTeacher
import ru.ysu.lms.web.api.updateCourse
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper
class AdminCourseControllerTest {
private val api = mockk<ApiClient>()
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
groups = listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z")),
teachers = listOf(CourseTeacherDto(accountId = "a1", assignedAt = "2026-01-01T00:00:00Z")),
)
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(AdminCourseController(api, MarkdownRenderer(), ObjectMapper()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientIdentityKt",
"ru.ysu.lms.web.api.ApiClientLabKt",
"ru.ysu.lms.web.api.ApiClientTeacherKt",
)
every { api.courseByUrlNo("token", "7") } returns course
every { api.listGroups("token") } returns listOf(GroupDto(id = "g1", name = "ПИ-21БО"))
every { api.listTeachers("token") } returns listOf(TeacherDto(id = "a1", login = "teacher", name = "Преподаватель"))
every { api.listLabs("token", "course-uuid") } returns emptyList<LabSummaryDto>()
every { api.createCourse("token", any()) } returns course
every { api.attachCourseGroup("token", "course-uuid", any()) } returns course
every { api.detachCourseGroup("token", "course-uuid", "g1") } returns course
every { api.assignCourseTeacher("token", "course-uuid", any()) } returns course
every { api.removeCourseTeacher("token", "course-uuid", "a1") } returns course
every { api.updateCourse("token", "course-uuid", any()) } returns course
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `detail page resolves course by number and calls api with uuid`() {
val model =
mockMvc
.perform(get("/admin/courses/7").session(session()))
.andExpect(status().isOk)
.andReturn()
.modelAndView!!
.model
assertThat((model["course"] as CourseDetailDto).courseNo).isEqualTo(7)
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.listLabs("token", "course-uuid") }
verify(exactly = 0) { api.courseDetail(any(), any()) }
}
@Test
fun `create redirects to course number instead of uuid`() {
mockMvc
.perform(post("/admin/courses").param("title", "Алгоритмы").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.createCourse("token", CourseCreatePayload("Алгоритмы")) }
}
@Test
fun `attach group resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/groups").param("groupId", "g1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.attachCourseGroup("token", "course-uuid", CourseGroupPayload("g1")) }
}
@Test
fun `detach group resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/groups/g1/detach").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.detachCourseGroup("token", "course-uuid", "g1") }
}
@Test
fun `assign teacher resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/teachers").param("accountId", "a1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.assignCourseTeacher("token", "course-uuid", CourseTeacherPayload("a1")) }
}
@Test
fun `remove teacher resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/teachers/a1/remove").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.removeCourseTeacher("token", "course-uuid", "a1") }
}
@Test
fun `status update resolves course number and redirects to it`() {
mockMvc
.perform(post("/admin/courses/7/status").param("status", "ARCHIVED").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses/7"))
verify(exactly = 1) { api.updateCourse("token", "course-uuid", CourseUpdatePayload(status = "ARCHIVED")) }
}
@Test
fun `unknown course number redirects back to course list`() {
every { api.courseByUrlNo("token", "abc") } throws ApiException(404, "Курс не найден")
mockMvc
.perform(post("/admin/courses/abc/groups").param("groupId", "g1").session(session()))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/courses"))
}
private fun session(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
setAttribute(
SessionKeys.USER,
SessionUser(
id = "admin-id",
login = "admin",
role = "ADMIN",
name = "Администратор",
mustChangePassword = false,
),
)
}
}

View File

@@ -0,0 +1,130 @@
package ru.ysu.lms.web.admin
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseDto
import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupOptionDto
import ru.ysu.lms.web.api.CourseTeacherDto
import ru.ysu.lms.web.api.TeacherDto
import ru.ysu.lms.web.session.SessionUser
import java.util.Locale
class AdminCourseTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
status = "ACTIVE",
createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-01-02T00:00:00Z",
groups = listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z")),
teachers = listOf(CourseTeacherDto(accountId = "a1", assignedAt = "2026-01-01T00:00:00Z")),
)
@Test
fun `courses list links to course by human number`() {
val html =
render(
"admin/courses",
mapOf(
"courses" to
listOf(
CourseDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
ownerAccountId = "admin-id",
status = "ACTIVE",
teacherCount = 1,
groupCount = 1,
labCount = 0,
),
),
"groups" to emptyList<Any>(),
"activeGroupCount" to 0,
),
)
assertThat(html).contains("href=\"/admin/courses/7\"")
assertThat(html).doesNotContain("course-uuid")
}
@Test
fun `course page forms and actions use human number`() {
val html =
render(
"admin/course",
mapOf(
"course" to course,
"attachedGroups" to course.groups,
"availableGroups" to listOf(CourseGroupOptionDto(id = "g2", name = "ПИ-22БО")),
"groupNames" to mapOf("g1" to "ПИ-21БО"),
"teachers" to listOf(TeacherDto(id = "a1", login = "teacher", name = "Преподаватель")),
"availableTeachers" to emptyList<Any>(),
"teacherNames" to mapOf("a1" to "Преподаватель (teacher)"),
"activeTeachers" to course.teachers,
"labs" to emptyList<Any>(),
),
)
assertThat(html).contains("/admin/courses/7/status")
assertThat(html).contains("/admin/courses/7/groups")
assertThat(html).contains("/admin/courses/7/groups/g1/detach")
assertThat(html).contains("/admin/courses/7/teachers")
assertThat(html).contains("/admin/courses/7/teachers/a1/remove")
assertThat(html).contains("/admin/groups/g1?courseNo=7")
assertThat(html).doesNotContain("/admin/groups/g1?courseId=")
assertThat(html).doesNotContain("/admin/courses/course-uuid")
}
private fun render(
template: String,
variables: Map<String, Any?>,
): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
context.setVariable("currentUser", sessionUser())
variables.forEach { (name, value) -> context.setVariable(name, value) }
return engine.process(template, context)
}
private fun sessionUser(): SessionUser =
SessionUser(
id = "admin-id",
login = "admin",
role = "ADMIN",
name = "Администратор",
mustChangePassword = false,
)
}

View File

@@ -0,0 +1,146 @@
package ru.ysu.lms.web.admin
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupRosterDto
import ru.ysu.lms.web.api.CourseGroupStudentDto
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.GroupLabDataDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.listAttestations
import ru.ysu.lms.web.api.listGrades
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.setPublication
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper
class AdminGroupControllerTest {
private val api = mockk<ApiClient>()
private val course =
CourseDetailDto(
id = "course-uuid",
courseNo = 7,
title = "Алгоритмы",
)
private val roster =
CourseGroupRosterDto(
groupId = "g1",
groupName = "ПИ-21БО",
students =
listOf(
CourseGroupStudentDto(id = "s1", studentNo = "123456", fullName = "Иванов Иван"),
),
)
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(AdminGroupController(api, MarkdownRenderer(), ObjectMapper()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientAttestationKt",
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientGroupKt",
"ru.ysu.lms.web.api.ApiClientIdentityKt",
"ru.ysu.lms.web.api.ApiClientLabKt",
"ru.ysu.lms.web.api.ApiClientProgressKt",
)
every { api.courseByUrlNo("token", "7") } returns course
every { api.courseGroupStudents("token", "course-uuid", "g1") } returns roster
every { api.listLabs("token") } returns
listOf(
LabSummaryDto(id = "l1", number = 1, title = "Введение", status = "published", courseId = "course-uuid"),
LabSummaryDto(id = "l2", number = 2, title = "Чужая", status = "published", courseId = "other-course"),
)
every { api.listGrades("token", "course-uuid") } returns emptyList()
every { api.groupLabData("token", "g1", listOf("l1")) } returns GroupLabDataDto(groupId = "g1")
every { api.setPublication("token", PublicationSetPayload("g1", "l1", listOf("s1"))) } just runs
every { api.listAttestations("token", "g1") } returns emptyList()
every { api.listGrades("token") } returns emptyList()
every { api.groupDetail("token", "g1") } returns GroupDetailDto(id = "g1", name = "ПИ-21БО")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `teacher gradebook resolves course number and scopes api calls by uuid`() {
val model =
mockMvc
.perform(get("/admin/groups/g1").param("courseNo", "7").session(teacherSession()))
.andExpect(status().isOk)
.andReturn()
.modelAndView!!
.model
assertThat(model["profileCourseNo"]).isEqualTo(7L)
assertThat(model["teacherView"]).isEqualTo(true)
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.courseGroupStudents("token", "course-uuid", "g1") }
verify(exactly = 1) { api.listGrades("token", "course-uuid") }
verify(exactly = 1) { api.groupLabData("token", "g1", listOf("l1")) }
}
@Test
fun `publish redirects back with course number instead of uuid`() {
mockMvc
.perform(
post("/admin/groups/g1/publish")
.param("courseNo", "7")
.param("labId", "l1")
.param("published", "true")
.session(teacherSession()),
).andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/admin/groups/g1?courseNo=7"))
verify(exactly = 1) { api.courseByUrlNo("token", "7") }
verify(exactly = 1) { api.courseGroupStudents("token", "course-uuid", "g1") }
verify(exactly = 1) { api.setPublication("token", PublicationSetPayload("g1", "l1", listOf("s1"))) }
}
private fun teacherSession(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
setAttribute(
SessionKeys.USER,
SessionUser(
id = "t1",
login = "teacher",
role = "TEACHER",
name = "Преподаватель",
mustChangePassword = false,
),
)
}
}

View File

@@ -197,7 +197,7 @@ class AdminStage4TemplateTest {
"labSubmissionCounts" to emptyMap<String, Int>(), "labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(), "attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0, "publishedLabsCount" to 0,
"profileCourseId" to null, "profileCourseNo" to null,
"teacherView" to false, "teacherView" to false,
) )
@@ -243,7 +243,7 @@ class AdminStage4TemplateTest {
"labSubmissionCounts" to emptyMap<String, Int>(), "labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(), "attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0, "publishedLabsCount" to 0,
"profileCourseId" to null, "profileCourseNo" to null,
"teacherView" to false, "teacherView" to false,
"importResult" to "importResult" to
ImportResultDto( ImportResultDto(

View File

@@ -35,8 +35,10 @@ class TeacherGradebookTemplateTest {
assertThat(html).contains("action=\"/admin/groups/g1/grades\"") assertThat(html).contains("action=\"/admin/groups/g1/grades\"")
assertThat(html).contains("action=\"/admin/defenses/save\"") assertThat(html).contains("action=\"/admin/defenses/save\"")
assertThat(html).contains("/admin/groups/g1/publish") assertThat(html).contains("/admin/groups/g1/publish")
assertThat(html).contains("name=\"courseId\"") assertThat(html).contains("name=\"courseNo\"")
assertThat(html).contains("value=\"c1\"") assertThat(html).contains("value=\"1\"")
assertThat(html).contains("courseNo=1")
assertThat(html).doesNotContain("courseId")
} }
@Test @Test
@@ -62,8 +64,9 @@ class TeacherGradebookTemplateTest {
fun `teacher gradebook links profiles to teacher course`() { fun `teacher gradebook links profiles to teacher course`() {
val html = render() val html = render()
assertThat(html).contains("/teacher/courses/c1/students/s1") assertThat(html).contains("/teacher/courses/1/students/s1")
assertThat(html).doesNotContain("/admin/courses/c1/students/s1") assertThat(html).doesNotContain("/admin/courses/1/students/s1")
assertThat(html).doesNotContain("/teacher/courses/c1/students/s1")
} }
@Test @Test
@@ -72,8 +75,9 @@ class TeacherGradebookTemplateTest {
assertThat(html).contains("/credentials") assertThat(html).contains("/credentials")
assertThat(html).contains("/students/s1/reset-password") assertThat(html).contains("/students/s1/reset-password")
assertThat(html).contains("/admin/courses/c1/students/s1") assertThat(html).contains("/admin/courses/1/students/s1")
assertThat(html).doesNotContain("/teacher/courses/c1/students/s1") assertThat(html).doesNotContain("/teacher/courses/1/students/s1")
assertThat(html).doesNotContain("courseId")
} }
private fun render(teacherView: Boolean? = true): String { private fun render(teacherView: Boolean? = true): String {
@@ -103,7 +107,7 @@ class TeacherGradebookTemplateTest {
val context = WebContext(exchange, Locale.forLanguageTag("ru")) val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf") context.setVariable("csrfToken", "csrf")
if (teacherView != null) context.setVariable("teacherView", teacherView) if (teacherView != null) context.setVariable("teacherView", teacherView)
context.setVariable("profileCourseId", "c1") context.setVariable("profileCourseNo", 1L)
context.setVariable("group", group) context.setVariable("group", group)
context.setVariable("labs", listOf(lab)) context.setVariable("labs", listOf(lab))
context.setVariable("candidates", emptyList<Any>()) context.setVariable("candidates", emptyList<Any>())

View File

@@ -51,10 +51,13 @@ class StudentProfileTemplateTest {
assertThat(html).contains("Разделите функцию на части") assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время") assertThat(html).contains("кончилось время")
assertThat(html).contains("main.c") assertThat(html).contains("main.c")
assertThat(html).contains("/admin/courses/c1/submissions/s1/download") assertThat(html).contains("/admin/courses/1/submissions/s1/download")
assertThat(html).contains("studentNo=123456") assertThat(html).contains("studentNo=123456")
assertThat(html).contains("attempt=2") assertThat(html).contains("attempt=2")
assertThat(html).contains("← Все курсы") assertThat(html).contains("← Все курсы")
assertThat(html).contains("href=\"/admin/courses/1\"")
assertThat(html).doesNotContain("/admin/courses/c1")
assertThat(html).doesNotContain("/courses/c1/")
} }
@Test @Test
@@ -62,7 +65,10 @@ class StudentProfileTemplateTest {
val html = render(isAdmin = false) val html = render(isAdmin = false)
assertThat(html).contains("← Мои курсы") assertThat(html).contains("← Мои курсы")
assertThat(html).contains("/teacher/courses/c1/submissions/s1/download") assertThat(html).contains("/teacher/courses/1/submissions/s1/download")
assertThat(html).contains("href=\"/teacher/courses/1\"")
assertThat(html).doesNotContain("/teacher/courses/c1")
assertThat(html).doesNotContain("/courses/c1/")
} }
private fun render(isAdmin: Boolean): String { private fun render(isAdmin: Boolean): String {
@@ -76,16 +82,16 @@ class StudentProfileTemplateTest {
val context = WebContext(exchange, Locale.forLanguageTag("ru")) val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("profile", sampleProfile()) context.setVariable("profile", sampleProfile())
context.setVariable("isAdmin", isAdmin) context.setVariable("isAdmin", isAdmin)
context.setVariable("courseUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1") context.setVariable("courseUrl", if (isAdmin) "/admin/courses/1" else "/teacher/courses/1")
context.setVariable("backUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1") context.setVariable("backUrl", if (isAdmin) "/admin/courses/1" else "/teacher/courses/1")
context.setVariable("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы") context.setVariable("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы")
context.setVariable("backTitle", "Вернуться к курсу") context.setVariable("backTitle", "Вернуться к курсу")
context.setVariable( context.setVariable(
"downloadBase", "downloadBase",
if (isAdmin) { if (isAdmin) {
"/admin/courses/{courseId}/submissions/{id}/download" "/admin/courses/{courseNo}/submissions/{id}/download"
} else { } else {
"/teacher/courses/{courseId}/submissions/{id}/download" "/teacher/courses/{courseNo}/submissions/{id}/download"
}, },
) )
return engine.process("profile/student", context) return engine.process("profile/student", context)

View File

@@ -12,15 +12,18 @@ import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.test.web.servlet.setup.MockMvcBuilders
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupDto import ru.ysu.lms.web.api.CourseGroupDto
import ru.ysu.lms.web.api.CourseGroupOptionDto import ru.ysu.lms.web.api.CourseGroupOptionDto
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.attachCourseGroup
import ru.ysu.lms.web.api.courseAvailableGroups import ru.ysu.lms.web.api.courseAvailableGroups
import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseGroupStudents import ru.ysu.lms.web.api.courseGroupStudents
import ru.ysu.lms.web.api.courseGroups import ru.ysu.lms.web.api.courseGroups
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
@@ -38,7 +41,7 @@ class TeacherControllerCourseGroupsTest {
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
mockkStatic("ru.ysu.lms.web.api.ApiClientCourseKt", "ru.ysu.lms.web.api.ApiClientLabKt") mockkStatic("ru.ysu.lms.web.api.ApiClientCourseKt", "ru.ysu.lms.web.api.ApiClientLabKt")
every { api.courseDetail("token", "c1") } returns every { api.courseByUrlNo("token", "1") } returns
CourseDetailDto( CourseDetailDto(
id = "c1", id = "c1",
courseNo = 1, courseNo = 1,
@@ -84,9 +87,26 @@ class TeacherControllerCourseGroupsTest {
verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) } verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) }
} }
@Test
fun `attach redirects to course page by human number`() {
every { api.attachCourseGroup("token", "c1", CourseGroupPayload("g2")) } returns
CourseDetailDto(id = "c1", courseNo = 1, title = "Программирование")
val result =
mockMvc
.perform(
post("/teacher/courses/1/groups/attach")
.session(session())
.param("groupId", "g2"),
).andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher/courses/1")
verify(exactly = 1) { api.attachCourseGroup("token", "c1", CourseGroupPayload("g2")) }
}
private fun renderModel() = private fun renderModel() =
mockMvc mockMvc
.perform(get("/teacher/courses/c1").session(session())) .perform(get("/teacher/courses/1").session(session()))
.andReturn() .andReturn()
.modelAndView!! .modelAndView!!
.model .model

View File

@@ -0,0 +1,102 @@
package ru.ysu.lms.web.teacher
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
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.ContentChapterNodeDto
import ru.ysu.lms.web.api.ContentTreeNodeDto
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.courseByUrlNo
import ru.ysu.lms.web.api.courseContentTree
import ru.ysu.lms.web.api.createCourseChapter
import ru.ysu.lms.web.session.SessionKeys
/** Браузерные URL материалов курса используют номер курса, а REST API — UUID курса. */
class TeacherCourseContentControllerTest {
private val api = mockk<ApiClient>()
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(TeacherCourseContentController(api, MarkdownRenderer()))
.build()
@BeforeEach
fun setUp() {
mockkStatic(
"ru.ysu.lms.web.api.ApiClientCourseKt",
"ru.ysu.lms.web.api.ApiClientContentKt",
"ru.ysu.lms.web.api.ApiClientCourseContentKt",
)
every { api.courseByUrlNo("token", "42") } returns
CourseDetailDto(id = "c1", courseNo = 42, title = "Алгоритмы")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `content page resolves course by human number and keeps uuid for api`() {
every { api.courseContentTree("token", "c1") } returns ContentTreeNodeDto()
val result =
mockMvc
.perform(get("/teacher/courses/42/content").session(session()))
.andReturn()
assertThat(result.modelAndView!!.viewName).isEqualTo("teacher/content")
assertThat(result.modelAndView!!.model["course"])
.isEqualTo(CourseDetailDto(id = "c1", courseNo = 42, title = "Алгоритмы"))
verify(exactly = 1) { api.courseByUrlNo("token", "42") }
verify(exactly = 1) { api.courseContentTree("token", "c1") }
}
@Test
fun `chapter creation redirects back to content page by course number`() {
every { api.createCourseChapter("token", "c1", "Введение", null) } returns
ContentChapterNodeDto(id = "ch1", title = "Введение")
val result =
mockMvc
.perform(
post("/teacher/courses/42/content/chapters")
.session(session())
.param("title", "Введение"),
).andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher/courses/42/content")
verify(exactly = 1) { api.createCourseChapter("token", "c1", "Введение", null) }
}
@Test
fun `unknown course number redirects to teacher dashboard`() {
every { api.courseByUrlNo("token", "999") } throws ApiException(404, "Курс не найден")
val result =
mockMvc
.perform(get("/teacher/courses/999/content").session(session()))
.andReturn()
assertThat(result.response.redirectedUrl).isEqualTo("/teacher")
}
private fun session(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "token")
}
}

View File

@@ -71,7 +71,7 @@ class TeacherCourseContentTemplateTest {
assertThat(html).contains("Материалы курса") assertThat(html).contains("Материалы курса")
assertThat(html).contains("Алгоритмы") assertThat(html).contains("Алгоритмы")
assertThat(html).contains("action=\"/teacher/courses/c1/content/chapters\"") assertThat(html).contains("action=\"/teacher/courses/42/content/chapters\"")
assertThat(html).contains("name=\"title\"") assertThat(html).contains("name=\"title\"")
assertThat(html).contains("Введение") assertThat(html).contains("Введение")
assertThat(html).contains("Основы") assertThat(html).contains("Основы")
@@ -86,24 +86,24 @@ class TeacherCourseContentTemplateTest {
fun `content page links articles to teacher edit page and publishes every level`() { fun `content page links articles to teacher edit page and publishes every level`() {
val html = render("teacher/content") val html = render("teacher/content")
assertThat(html).contains("/teacher/courses/c1/content/intro/basics/first-step") assertThat(html).contains("/teacher/courses/42/content/intro/basics/first-step")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/published") assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/published")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/published") assertThat(html).contains("/teacher/courses/42/content/sections/sec1/published")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/published") assertThat(html).contains("/teacher/courses/42/content/articles/art1/published")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/rename") assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/rename")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/rename") assertThat(html).contains("/teacher/courses/42/content/sections/sec1/rename")
assertThat(html).contains("/teacher/courses/c1/content/chapters/ch1/delete") assertThat(html).contains("/teacher/courses/42/content/chapters/ch1/delete")
assertThat(html).contains("/teacher/courses/c1/content/sections/sec1/delete") assertThat(html).contains("/teacher/courses/42/content/sections/sec1/delete")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/delete") assertThat(html).contains("/teacher/courses/42/content/articles/art1/delete")
} }
@Test @Test
fun `content page renders article and section creation forms`() { fun `content page renders article and section creation forms`() {
val html = render("teacher/content") val html = render("teacher/content")
assertThat(html).contains("action=\"/teacher/courses/c1/content/sections\"") assertThat(html).contains("action=\"/teacher/courses/42/content/sections\"")
assertThat(html).contains("name=\"chapterId\"") assertThat(html).contains("name=\"chapterId\"")
assertThat(html).contains("action=\"/teacher/courses/c1/content/articles\"") assertThat(html).contains("action=\"/teacher/courses/42/content/articles\"")
assertThat(html).contains("name=\"sectionId\"") assertThat(html).contains("name=\"sectionId\"")
assertThat(html).contains("name=\"bodyMd\"") assertThat(html).contains("name=\"bodyMd\"")
assertThat(html).contains("Добавить статью") assertThat(html).contains("Добавить статью")
@@ -130,16 +130,16 @@ class TeacherCourseContentTemplateTest {
mapOf( mapOf(
"article" to article, "article" to article,
"bodyHtml" to "<p>Текст статьи</p>", "bodyHtml" to "<p>Текст статьи</p>",
"back" to "/teacher/courses/c1/content/intro/basics/first-step", "back" to "/teacher/courses/42/content/intro/basics/first-step",
), ),
) )
assertThat(html).contains("name=\"bodyMd\"") assertThat(html).contains("name=\"bodyMd\"")
assertThat(html).contains("rows=\"18\"") assertThat(html).contains("rows=\"18\"")
assertThat(html).contains("# Первый шаг") assertThat(html).contains("# Первый шаг")
assertThat(html).contains("action=\"/teacher/courses/c1/content/articles/art1/save\"") assertThat(html).contains("action=\"/teacher/courses/42/content/articles/art1/save\"")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/delete") assertThat(html).contains("/teacher/courses/42/content/articles/art1/delete")
assertThat(html).contains("/teacher/courses/c1/content/articles/art1/published") assertThat(html).contains("/teacher/courses/42/content/articles/art1/published")
assertThat(html).contains("<p>Текст статьи</p>") assertThat(html).contains("<p>Текст статьи</p>")
assertThat(html).contains("name=\"_csrf\"") assertThat(html).contains("name=\"_csrf\"")
assertThat(html).contains("/learn/course/42/intro/basics/first-step") assertThat(html).contains("/learn/course/42/intro/basics/first-step")

View File

@@ -35,16 +35,27 @@ class TeacherCourseTemplateTest {
assertThat(html).contains("ПИ-21БО") assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("/admin/groups/g1") assertThat(html).contains("/admin/groups/g1")
assertThat(html).contains("courseId=c1") assertThat(html).contains("courseNo=1")
assertThat(html).doesNotContain("courseId")
assertThat(html).contains("Ведомость") assertThat(html).contains("Ведомость")
assertThat(html).contains("/teacher/courses/c1/groups/detach") assertThat(html).contains("/teacher/courses/1/groups/detach")
assertThat(html).doesNotContain("/teacher/courses/c1")
}
@Test
fun `course links use human course number instead of uuid`() {
val html = render()
assertThat(html).contains("/teacher/courses/1/content")
assertThat(html).contains("/teacher/courses/1/groups/g1")
assertThat(html).doesNotContain("/teacher/courses/c1")
} }
@Test @Test
fun `renders group attach form with available groups`() { fun `renders group attach form with available groups`() {
val html = render() val html = render()
assertThat(html).contains("action=\"/teacher/courses/c1/groups/attach\"") assertThat(html).contains("action=\"/teacher/courses/1/groups/attach\"")
assertThat(html).contains("name=\"groupId\"") assertThat(html).contains("name=\"groupId\"")
assertThat(html).contains("ПИ-22БО") assertThat(html).contains("ПИ-22БО")
assertThat(html).contains("Привязать") assertThat(html).contains("Привязать")

View File

@@ -34,8 +34,8 @@ class TeacherGroupTemplateTest {
assertThat(html).contains("ПИ-21БО") assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("Иванов Иван") assertThat(html).contains("Иванов Иван")
assertThat(html).contains("123456") assertThat(html).contains("123456")
assertThat(html).contains("/teacher/courses/c1") assertThat(html).contains("/teacher/courses/42")
assertThat(html).contains("/teacher/courses/c1/students/s1") assertThat(html).contains("/teacher/courses/42/students/s1")
assertThat(html).contains("активен") assertThat(html).contains("активен")
} }
@@ -56,7 +56,7 @@ class TeacherGroupTemplateTest {
MockHttpServletResponse(), MockHttpServletResponse(),
) )
val context = WebContext(exchange, Locale.forLanguageTag("ru")) val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("courseId", "c1") context.setVariable("courseNo", 42L)
context.setVariable("groupId", "g1") context.setVariable("groupId", "g1")
context.setVariable( context.setVariable(
"roster", "roster",