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:
10
README.md
10
README.md
@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
|||||||
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
|
отдельной 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` с фильтром по действию,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ assets/ # картинки для md
|
|||||||
|
|
||||||
Материалы видны всем; глобальные материалы редактирует только администратор
|
Материалы видны всем; глобальные материалы редактирует только администратор
|
||||||
(«Материалы»). Преподаватель редактирует материалы своего курса на
|
(«Материалы»). Преподаватель редактирует материалы своего курса на
|
||||||
`/teacher/courses/{id}/content`.
|
`/teacher/courses/{courseNo}/content`.
|
||||||
|
|
||||||
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
|
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
|
||||||
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и
|
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (доступ и профиль студента курса) — следующий.
|
||||||
|
|
||||||
|
|||||||
@@ -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 (повышение и жизненный цикл) — следующий.
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 с) зелёные.
|
||||||
|
|||||||
@@ -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-активов и
|
||||||
|
|||||||
@@ -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 с.
|
||||||
|
|
||||||
|
|||||||
@@ -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, миграции, доступ, статусы, повышение |
|
||||||
|
|
||||||
## Кратко о системе
|
## Кратко о системе
|
||||||
|
|||||||
@@ -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, контракты, импорт,
|
||||||
|
|||||||
@@ -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 зелёные. Это достаточное основание продолжать доработки, но не
|
||||||
«формальное доказательство всей системы».
|
«формальное доказательство всей системы».
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, "Выберите группу") }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Массовая установка уровня выделенным студентам. */
|
/** Массовая установка уровня выделенным студентам. */
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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("Привязать")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user