diff --git a/README.md b/README.md index 53beb08..539a4fc 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). - Baseline: **58 claims, 368 тестов, 18/18 browser E2E**. + Baseline: **59 claims, 385 тестов, 18/18 browser E2E**. ## Запуск в Docker (для сервера) @@ -152,16 +152,16 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD с одноразовым паролем; преподаватель видит только назначенные курсы (`/teacher`), их группы, студентов и профили. Курсы и лабораторные фильтруются по роли, внутренние вызовы identity защищены `LMS_INTERNAL_TOKEN`. -- **Профиль студента курса**: `/teacher/courses/{id}/students/{id}` и админский +- **Профиль студента курса**: `/teacher/courses/{courseNo}/students/{studentId}` и админский маршрут показывают сводку, оценки, комментарии с авторами, защиты и все попытки сдачи со скачиванием ZIP; студенту API отдаёт только его профиль и опубликованные данные. - **Работа преподавателя**: ведомость группы переиспользует - `/admin/groups/{groupId}?courseId=...` без админских контролов — оценки и + `/admin/groups/{groupId}?courseNo=...` без админских контролов — оценки и комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), скачивание попыток и ZIP группы; лабораторные/уровни/варианты своего курса, - привязка/отвязка групп на `/teacher/courses/{id}`, авторские материалы на - `/teacher/courses/{id}/content`. Чужой курс отклоняется на сервисах. + привязка/отвязка групп на `/teacher/courses/{courseNo}`, авторские материалы на + `/teacher/courses/{courseNo}/content`. Чужой курс отклоняется на сервисах. - **Аудит**: чувствительные действия (профили, скачивания, оценки, защиты, курсы, группы, материалы, преподаватели, жизненный цикл) пишутся в `identity.audit_events`; админ смотрит `/admin/audit` с фильтром по действию, diff --git a/deploy/IMPORT.md b/deploy/IMPORT.md index 38f951d..857a8ef 100644 --- a/deploy/IMPORT.md +++ b/deploy/IMPORT.md @@ -103,7 +103,7 @@ assets/ # картинки для md Материалы видны всем; глобальные материалы редактирует только администратор («Материалы»). Преподаватель редактирует материалы своего курса на -`/teacher/courses/{id}/content`. +`/teacher/courses/{courseNo}/content`. Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве» (синхронизация — архив считается источником истины) и без неё (merge — только добавить и diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index ad65a0f..9ffa2ab 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13. - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events` с `/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-матрица, - gateway, видимость активов и deploy-runbook. LDD baseline: **58 claims - (27 P0 / 31 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: + gateway, видимость активов и deploy-runbook. LDD baseline: **59 claims + (27 P0 / 32 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: [16-stage6-report.md](16-stage6-report.md). ### 9.1. Известные пробелы после этапа 6 diff --git a/docs/12-stage2-report.md b/docs/12-stage2-report.md index 9670e15..747a87b 100644 --- a/docs/12-stage2-report.md +++ b/docs/12-stage2-report.md @@ -1,5 +1,7 @@ # 12. Отчёт этапа 2: academic (курсы и лабораторные) +> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID. + **Дата:** 2026-10-27. **Статус:** этап 2 выполнен; этап 3 (доступ и профиль студента курса) — следующий. diff --git a/docs/13-stage3-report.md b/docs/13-stage3-report.md index e1b3e24..aeca194 100644 --- a/docs/13-stage3-report.md +++ b/docs/13-stage3-report.md @@ -1,5 +1,7 @@ # 13. Отчёт этапа 3: доступ и профиль студента курса +> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID. + **Дата:** 2026-10-27. **Статус:** этап 3 выполнен; этап 4 (повышение и жизненный цикл) — следующий. diff --git a/docs/15-stage5-course-materials.md b/docs/15-stage5-course-materials.md index 7518c4b..dd46f37 100644 --- a/docs/15-stage5-course-materials.md +++ b/docs/15-stage5-course-materials.md @@ -64,7 +64,7 @@ ## Авторство материалов преподавателем (добавлено на этапе 6) Преподаватель своего курса редактирует главы, разделы и статьи на -`/teacher/courses/{id}/content`: создание/переименование/удаление, markdown- +`/teacher/courses/{courseNo}/content`: создание/переименование/удаление, markdown- редактор с предпросмотром, публикация/снятие с публикации. API — `/api/v1/courses/{courseId}/content/**`; любая изменяемая сущность проверяется на принадлежность курсу. Глобальные материалы (`course_id IS NULL`, diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md index 173dea3..133369f 100644 --- a/docs/16-stage6-report.md +++ b/docs/16-stage6-report.md @@ -68,12 +68,12 @@ | identity-service | 105 | 0 | 0 | 0 | | academic-service | 106 | 0 | 0 | 0 | | progress-service | 38 | 0 | 0 | 0 | -| web | 112 | 0 | 0 | 0 | +| web | 129 | 0 | 0 | 0 | | gateway | 2 | 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; маршрутизация gateway дополнительно проверена конфигурацией и security-матрицей ниже. Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на @@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией ### 2.3. Security-проверки -Проверено автоматическими тестами (актуальный прогон — 368 тестов, 0 failures) +Проверено автоматическими тестами (актуальный прогон — 385 тестов, 0 failures) и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта negative-тестами «свой/чужой курс». @@ -252,10 +252,10 @@ legacy-активов на production, сдачи по выбранной лаб `/admin/labs`, `/admin/teachers`, `/admin/promotion`. - [ ] **Преподаватель:** вход, `/teacher`, карточка своего курса (имена групп, привязка/отвязка группы по `available-groups`), ведомость - `/admin/groups/{groupId}?courseId=...` без админских контролов, оценка и + `/admin/groups/{groupId}?courseNo=...` без админских контролов, оценка и публикация, уровень/вариант и раздача, защита (код/reset), профиль студента, скачивание попытки и ZIP группы, авторство материалов - `/teacher/courses/{id}/content`. + `/teacher/courses/{courseNo}/content`. - [ ] **Студент:** вход, дашборд, лабораторная, сдача/история, курсовые материалы; видны только опубликованные оценки и комментарии. - [ ] **Материалы курса:** админ и преподаватель видят скрытые статьи, студент — @@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key ## 7. LDD baseline После этапа 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, 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); конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. diff --git a/docs/17-handoff.md b/docs/17-handoff.md index 16353f8..aa35560 100644 --- a/docs/17-handoff.md +++ b/docs/17-handoff.md @@ -116,8 +116,8 @@ ### 1.8. LDD и проверки - Реестр: `docs/evidence/claims.json`. -- **58 claims**: 27 P0 + 31 P1; **58/58 proven, P0 gaps = 0**. -- **368 автотестов**, 0 failures/errors/skipped. +- **59 claims**: 27 P0 + 32 P1; **59/59 proven, P0 gaps = 0**. +- **385 автотестов**, 0 failures/errors/skipped. - Mutation testing: - `CourseAccessService` — 75% test strength; - `PromotionPlanner` — 69%. @@ -194,8 +194,8 @@ cat build/reports/evidence/index.md ``` Последний подтверждённый результат: -- `368` тестов, 0 failures; -- `58/58` claims, P0 gaps = 0; +- `385` тестов, 0 failures; +- `59/59` claims, P0 gaps = 0; - `ldd-release.sh` — PASS; - smoke — PASS; - browser E2E — 18/18 (Chromium/Firefox/WebKit); @@ -275,8 +275,8 @@ cat build/reports/evidence/index.md `feature/groups-courses`. 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и `docs/evidence/baseline-2026-10-27.md`. -3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 368 тестов - и 58 claims, 58/58 proven. +3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 385 тестов + и 59 claims, 59/59 proven. 4. Для release-проверки: `./scripts/ldd-release.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и diff --git a/docs/18-goal-audit.md b/docs/18-goal-audit.md index 8a64062..785e717 100644 --- a/docs/18-goal-audit.md +++ b/docs/18-goal-audit.md @@ -20,13 +20,13 @@ | Импорт v2 | partial | Есть флаг восстановления неактивных; row-level отчёт для DB-конфликтов неполный; legacy-группы с NULL-полями | | Курсы, преподаватели, группы курса | implemented | — | | Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` | -| Профиль студента | partial | Браузерный URL использует UUID вместо `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» | +| Профиль студента | partial | URL уже `courseNo`; часть полей контракта 09 отличается; нет лимита/пагинации попыток; нет SQL-теста «без N+1» | | Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта | | Аудит | partial | Добавлены `GROUP_CREATE`, `STUDENT_CREATE`, `GROUP_IMPORT`, `GROUP_ENROLL`; отдельных тестов на эти события пока нет | | Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение | | CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён | | Производительность | 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 не выполнена | | «Контингент» | out of scope | Исключён решением 2026-10-28 | @@ -79,7 +79,11 @@ - Старые комментарии без автора показывают «автор не указан». - Студенческий профиль не показывает черновики лабораторных. -10. **Импорт: ошибки до записи.** +10. **Браузерные URL курсов — `courseNo`.** + Страницы преподавателя, админа, материалы и профиль студента принимают + человеческий номер курса; REST API продолжает работать с UUID. + +11. **Импорт: ошибки до записи.** - Занятый логин и неактивная целевая группа возвращают `applied=false` с номером строки и не создают частичных записей. - Тесты: `GroupServiceIntegrationTest#import reports occupied login...`, @@ -89,9 +93,9 @@ ### P1 -- **Профиль: контракт.** Браузерные маршруты используют UUID курса, а docs/09 - обещает `courseNo`; часть полей `variant/level/grade` отличается от контракта. - Нужно либо привести код к контракту, либо официально обновить контракт. +- **Профиль: поля контракта.** URL уже используют `courseNo`, но часть полей + `variant/level/grade` и авторы комментариев отличаются от docs/09; нужен + либо код к контракту, либо официальное обновление контракта. - **Backup restore на staging** не подтверждён артефактом/логом. - **Первый реальный CI pipeline** на Woodpecker VM не подтверждён. @@ -138,9 +142,9 @@ ## 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. -- `./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, runtime/load p95 < 1 с. diff --git a/docs/README.md b/docs/README.md index 1de1da7..72936c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | -| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 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, миграции, доступ, статусы, повышение | ## Кратко о системе diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 3fb5f8e..9b662b5 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4. LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых рисков: -- **368 автоматических тестов**, 0 failures/errors; -- **58 claims**: 27 P0 + 31 P1, **58/58 proven, P0 gaps = 0**. P0 — доступы, +- **385 автоматических тестов**, 0 failures/errors; +- **59 claims**: 27 P0 + 32 P1, **59/59 proven, P0 gaps = 0**. P0 — доступы, повышение, жизненный цикл, профиль/скачивание, материалы/активы (включая secure-by-default legacy-файлы), миграции V11–V13, аудит, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md index bea95d6..50c7e0a 100644 --- a/docs/evidence/baseline-2026-10-27.md +++ b/docs/evidence/baseline-2026-10-27.md @@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew test --rerun-tasks ``` -Результат: **368 тестов, 0 failures, 0 errors, 0 skipped**. +Результат: **385 тестов, 0 failures, 0 errors, 0 skipped**. `ktlintCheck` и `detekt` — зелёные. ### 2.2. LDD gate @@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew evidenceCheck ``` -Результат: **58/58 claims proven, P0 gaps = 0**. +Результат: **59/59 claims proven, P0 gaps = 0**. Отчёт: `build/reports/evidence/index.md`. Gate устроен так: @@ -306,8 +306,8 @@ optional json fields`. ## 5. Вывод -Текущая функциональность прошла LDD-контур: **58/58 claims proven, P0 gaps = 0**, -**368 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и +Текущая функциональность прошла LDD-контур: **59/59 claims proven, P0 gaps = 0**, +**385 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и масштабный load зелёные. Это достаточное основание продолжать доработки, но не «формальное доказательство всей системы». diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index 649b7e3..1dae1fe 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -854,6 +854,24 @@ "ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()" ], "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." } ] } \ No newline at end of file diff --git a/e2e/lms.spec.ts b/e2e/lms.spec.ts index ccb2769..bc680ee 100644 --- a/e2e/lms.spec.ts +++ b/e2e/lms.spec.ts @@ -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 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("body")).toContainText(seeded.groupName); await expect(page.locator("body")).toContainText("E2E Lab"); 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.studentFullName); 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 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 page.goto(`/teacher/courses/${seeded.courseId}/content`); + await page.goto(`/teacher/courses/${seeded.courseNo}/content`); await page.fill("#chapterTitle", "E2E Chapter"); await page.getByRole("button", { name: "Создать главу" }).click(); 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.setViewportSize({ width: 390, height: 844 }); 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("body")).toContainText(seeded.groupName); }); diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt index 16802d9..bc16587 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt @@ -21,7 +21,7 @@ import ru.ysu.lms.web.api.CourseUpdatePayload import ru.ysu.lms.web.api.assignCourseTeacher import ru.ysu.lms.web.api.attachCourseGroup 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.detachCourseGroup import ru.ysu.lms.web.api.listCourses @@ -61,7 +61,7 @@ class AdminCourseController( try { val course = api.createCourse(token(session), CourseCreatePayload(form.title?.trim()?.takeIf { it.isNotBlank() })) redirect.addFlashAttribute("notice", "Курс №${course.courseNo} создан") - "redirect:/admin/courses/${course.id}" + "redirect:/admin/courses/${course.courseNo}" } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/courses" @@ -105,14 +105,14 @@ class AdminCourseController( if (chaptersWithoutCourse > 0) append("; глав без курса: $chaptersWithoutCourse") } - @GetMapping("/{id}") + @GetMapping("/{courseNo}") fun detail( - @PathVariable id: String, + @PathVariable courseNo: String, session: HttpSession, model: Model, ): String { val t = token(session) - val course = api.courseDetail(t, id) + val course = api.courseByUrlNo(t, courseNo) val groups = api.listGroups(t) val attachedGroups = course.groups.filter { it.removedAt == null } val attachedIds = attachedGroups.map { it.groupId }.toSet() @@ -127,92 +127,110 @@ class AdminCourseController( model.addAttribute("availableTeachers", teachers.filter { it.id !in activeTeacherIds }) model.addAttribute("teacherNames", teachers.associate { it.id to teacherLabel(it.name, it.login) }) model.addAttribute("activeTeachers", activeTeachers) - model.addAttribute("labs", api.listLabs(t, id)) + model.addAttribute("labs", api.listLabs(t, course.id)) return "admin/course" } - @PostMapping("/{id}/groups") + @PostMapping("/{courseNo}/groups") fun attachGroup( - @PathVariable id: String, + @PathVariable courseNo: String, @ModelAttribute form: CourseGroupForm, session: HttpSession, redirect: RedirectAttributes, ): String = 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:/admin/courses/$id" + courseRedirect(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/courses/$id" + courseRedirect(courseNo) } - @PostMapping("/{id}/groups/{groupId}/detach") + @PostMapping("/{courseNo}/groups/{groupId}/detach") fun detachGroup( - @PathVariable id: String, + @PathVariable courseNo: String, @PathVariable groupId: String, session: HttpSession, redirect: RedirectAttributes, ): String = 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:/admin/courses/$id" + courseRedirect(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/courses/$id" + courseRedirect(courseNo) } - @PostMapping("/{id}/teachers") + @PostMapping("/{courseNo}/teachers") fun assignTeacher( - @PathVariable id: String, + @PathVariable courseNo: String, @ModelAttribute form: CourseTeacherForm, session: HttpSession, redirect: RedirectAttributes, ): String = 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:/admin/courses/$id" + courseRedirect(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/courses/$id" + courseRedirect(courseNo) } - @PostMapping("/{id}/teachers/{accountId}/remove") + @PostMapping("/{courseNo}/teachers/{accountId}/remove") fun removeTeacher( - @PathVariable id: String, + @PathVariable courseNo: String, @PathVariable accountId: String, session: HttpSession, redirect: RedirectAttributes, ): String = 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:/admin/courses/$id" + courseRedirect(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/courses/$id" + courseRedirect(courseNo) } - @PostMapping("/{id}/status") + @PostMapping("/{courseNo}/status") fun updateStatus( - @PathVariable id: String, + @PathVariable courseNo: String, @RequestParam status: String, session: HttpSession, redirect: RedirectAttributes, ): String = try { 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 "Курс возвращён в работу" redirect.addFlashAttribute("notice", label) - "redirect:/admin/courses/$id" + courseRedirect(course.courseNo) } catch (ex: ApiException) { 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 requireAccountId(form: CourseTeacherForm): String = diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt index ddd0a89..91ba8a7 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt @@ -62,17 +62,17 @@ class AdminDefenseController( fun assignDefense( @PathVariable labId: String, @ModelAttribute form: DefenseForm, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.assignDefense(token(session), form.studentId, labId, form.assignmentMd) redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(form.groupId, labId, courseId) + redirectToGroup(form.groupId, labId, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(form.groupId, labId, courseId) + redirectToGroup(form.groupId, labId, courseNo) } /** Назначение/правка индивидуального задания со страницы группы. */ @@ -80,17 +80,17 @@ class AdminDefenseController( fun saveDefense( @ModelAttribute form: DefenseForm, @RequestParam labId: String, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.assignDefense(token(session), form.studentId, labId, form.assignmentMd) redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(form.groupId, labId, courseId) + redirectToGroup(form.groupId, labId, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(form.groupId, labId, courseId) + redirectToGroup(form.groupId, labId, courseNo) } @PostMapping("/labs/{labId}/defense/import") @@ -119,17 +119,17 @@ class AdminDefenseController( @PathVariable id: String, @RequestParam labId: String, @RequestParam(required = false) groupId: String?, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val code = api.issueDefenseCode(token(session), id) redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") - redirectToGroup(groupId, labId, courseId) + redirectToGroup(groupId, labId, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId, courseId) + redirectToGroup(groupId, labId, courseNo) } @Suppress("LongParameterList") @@ -138,16 +138,16 @@ class AdminDefenseController( @PathVariable id: String, @RequestParam labId: String, @RequestParam(required = false) groupId: String?, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.resetDefense(token(session), id) redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") - redirectToGroup(groupId, labId, courseId) + redirectToGroup(groupId, labId, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId, courseId) + redirectToGroup(groupId, labId, courseNo) } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt index 0b4bf71..0dcf895 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt @@ -31,34 +31,34 @@ class AdminDistributeController( fun setStudentLevel( @PathVariable gid: String, @ModelAttribute form: StudentLevelForm, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId)) redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически") - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } @PostMapping("/groups/{gid}/pin-variant") fun pinStudentVariant( @PathVariable gid: String, @ModelAttribute form: PinVariantForm, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId)) redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}") - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } @Suppress("LongParameterList") @@ -67,7 +67,7 @@ class AdminDistributeController( @PathVariable gid: String, @RequestParam labId: String, @RequestParam(defaultValue = "false") force: Boolean, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -78,10 +78,10 @@ class AdminDistributeController( DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)), ) redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } /** Раздать варианты по всем опубликованным ЛР для одной группы. */ @@ -89,7 +89,7 @@ class AdminDistributeController( fun distributeAllForGroup( @PathVariable gid: String, @RequestParam(defaultValue = "false") force: Boolean, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -98,7 +98,7 @@ class AdminDistributeController( val labs = api.listLabs(t).filter { it.status == "published" } if (labs.isEmpty()) { redirect.addFlashAttribute("error", "Нет опубликованных лабораторных") - return groupRedirect(gid, courseId) + return groupRedirect(gid, courseNo) } val result = api.distribute( @@ -106,10 +106,10 @@ class AdminDistributeController( DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)), ) redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}") - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(gid, courseId) + groupRedirect(gid, courseNo) } /** Массовая установка уровня выделенным студентам. */ diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index bfcbf9a..164d108 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -19,6 +19,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes 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.CourseDetailDto import ru.ysu.lms.web.api.CreateGroupPayload import ru.ysu.lms.web.api.DefenseDto 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.SubmissionDto 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.createGroup import ru.ysu.lms.web.api.deleteCommentTemplate @@ -163,25 +164,34 @@ class AdminGroupController( @GetMapping("/groups/{id}") fun groupDetail( @PathVariable id: String, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, model: Model, ): String { val t = token(session) val teacher = isTeacher(session) - val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } + val course = resolveCourse(t, courseNo) val view = if (teacher) { - teacherGroupView(t, id, effectiveCourseId) + teacherGroupView(t, id, course) } else { adminGroupView(t, id) } fillGroupModel(model, view) model.addAttribute("teacherView", teacher) - model.addAttribute("profileCourseId", effectiveCourseId) + model.addAttribute("profileCourseNo", course?.courseNo) return "admin/group" } + private fun resolveCourse( + token: String, + rawCourseNo: String?, + ): CourseDetailDto? = + rawCourseNo + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { api.courseByUrlNo(token, it) } + /** Администратор видит все группы и аттестации; преподаватель — только свой курс. */ private fun adminGroupView( token: String, @@ -203,11 +213,10 @@ class AdminGroupController( private fun teacherGroupView( token: String, id: String, - courseId: String?, + course: CourseDetailDto?, ): GroupViewModel { - if (courseId == null) throw ApiException(400, "Не указан курс") - api.courseDetail(token, courseId) - val roster = api.courseGroupStudents(token, courseId, id) + if (course == null) throw ApiException(400, "Не указан курс") + val roster = api.courseGroupStudents(token, course.id, id) val group = GroupDetailDto( 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 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 = if (labs.isEmpty()) { emptyMap() @@ -414,13 +423,14 @@ class AdminGroupController( fun assignModal( @PathVariable id: String, @RequestParam labId: String, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, model: Model, ): String { val t = token(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 levels = api.getLevels(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("rows", rows) model.addAttribute("variantUsage", variantUsage) - val courseQuery = - courseId - ?.trim() - ?.takeIf { it.isNotBlank() } - ?.let { "&courseId=$it" } - .orEmpty() + val courseQuery = course?.let { "&courseNo=${it.courseNo}" }.orEmpty() model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery") return "admin/assign-fragment" } @@ -462,15 +467,12 @@ class AdminGroupController( private fun groupStudentsFor( token: String, groupId: String, - courseId: String?, + course: CourseDetailDto?, teacher: Boolean, ): List { if (!teacher) return api.groupDetail(token, groupId).students - val effectiveCourseId = - courseId?.trim()?.takeIf { it.isNotBlank() } - ?: throw ApiException(400, "Не указан курс") - api.courseDetail(token, effectiveCourseId) - return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student -> + if (course == null) throw ApiException(400, "Не указан курс") + return api.courseGroupStudents(token, course.id, groupId).students.map { student -> StudentRowDto( id = student.id, studentNo = student.studentNo, @@ -496,7 +498,7 @@ class AdminGroupController( fun setGrade( @PathVariable id: String, @ModelAttribute form: SetGradeForm, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -516,10 +518,10 @@ class AdminGroupController( ), ) redirect.addFlashAttribute("notice", "Оценка сохранена") - groupRedirect(id, courseId) + groupRedirect(id, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(id, courseId) + groupRedirect(id, courseNo) } /** Уровни и варианты лабораторной для модалки назначения варианта. */ @@ -578,15 +580,16 @@ class AdminGroupController( fun downloadGroupSubmissions( @PathVariable id: String, @RequestParam(required = false) labId: String?, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, ): ResponseEntity { val t = token(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 labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) } - val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name + val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == course?.id) } + val groupName = if (teacher) groupNameFor(t, id, course) else api.groupDetail(t, id).name val bytes = buildGroupArchive(t, groupName, studentById, labs) return zipAttachment("solutions-$groupName.zip", bytes) } @@ -595,10 +598,10 @@ class AdminGroupController( private fun groupNameFor( token: String, groupId: String, - courseId: String?, + course: CourseDetailDto?, ): String { - val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId - return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName } + val effectiveCourse = course ?: return groupId + return runCatching { api.courseGroupStudents(token, effectiveCourse.id, groupId).groupName } .getOrDefault("") .ifBlank { groupId } } @@ -610,19 +613,20 @@ class AdminGroupController( @PathVariable id: String, @RequestParam labId: String, @RequestParam published: Boolean, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { 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())) redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы") - groupRedirect(id, courseId) + groupRedirect(id, courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - groupRedirect(id, courseId) + groupRedirect(id, courseNo) } /** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */ @@ -634,12 +638,13 @@ class AdminGroupController( @RequestParam labId: String, @RequestParam studentId: String, @RequestParam published: Boolean, - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, ): Map = try { 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 state = api.publicationState(t, id, labId) val current = diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt index 1242c30..77f2a4c 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt @@ -26,6 +26,7 @@ import ru.ysu.lms.web.api.VariantSavePayload import ru.ysu.lms.web.api.addLevel import ru.ysu.lms.web.api.assignVariant 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.deleteLab import ru.ysu.lms.web.api.deleteLevel @@ -55,15 +56,16 @@ class AdminLabController( ) : AdminSupport(api, markdown, mapper) { @GetMapping("/labs") fun labs( - @RequestParam(required = false) courseId: String?, + @RequestParam(required = false) courseNo: String?, session: HttpSession, model: Model, ): String { val t = token(session) - val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } - model.addAttribute("labs", api.listLabs(t, selectedCourseId)) + val selected = courseNo?.trim()?.takeIf { it.isNotBlank() }?.let { api.courseByUrlNo(t, it) } + model.addAttribute("labs", api.listLabs(t, selected?.id)) model.addAttribute("courses", coursesFor(session)) - model.addAttribute("selectedCourseId", selectedCourseId) + model.addAttribute("selectedCourseNo", selected?.courseNo) + model.addAttribute("selectedCourseId", selected?.id) return "admin/labs" } @@ -151,7 +153,7 @@ class AdminLabController( model.addAttribute("courses", coursesFor(session)) model.addAttribute( "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( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt index f5d3a2a..60ccf7e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt @@ -150,25 +150,25 @@ abstract class AdminSupport( protected fun redirectToGroup( groupId: String?, labId: String?, - courseId: String? = null, + courseNo: String? = null, ): String = when { - !groupId.isNullOrBlank() -> groupRedirect(groupId, courseId) + !groupId.isNullOrBlank() -> groupRedirect(groupId, courseNo) !labId.isNullOrBlank() -> "redirect:/admin/labs/$labId" else -> "redirect:/admin/groups" } - /** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */ + /** Возврат на ведомость группы; преподаватель дополнительно сохраняет номер курса. */ protected fun groupRedirect( groupId: String, - courseId: String?, - ): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}" + courseNo: String?, + ): String = "redirect:/admin/groups/$groupId${courseQuery(courseNo)}" - private fun courseQuery(courseId: String?): String = - courseId + private fun courseQuery(courseNo: String?): String = + courseNo ?.trim() ?.takeIf { it.isNotBlank() } - ?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) } + ?.let { "?courseNo=" + URLEncoder.encode(it, StandardCharsets.UTF_8) } .orEmpty() protected fun buildGroupArchive( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt index a435dbc..1b66fbf 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt @@ -57,6 +57,15 @@ fun ApiClient.courseDetail( .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`). */ fun ApiClient.courseByNo( token: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt b/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt index 38919d7..411eb62 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/profile/StudentProfileController.kt @@ -14,6 +14,7 @@ import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.StudentCourseProfileDto +import ru.ysu.lms.web.api.courseByUrlNo import ru.ysu.lms.web.api.downloadCourseSubmission import ru.ysu.lms.web.api.studentProfile import ru.ysu.lms.web.session.SessionKeys @@ -26,32 +27,34 @@ class StudentProfileController( private val api: ApiClient, private val markdown: MarkdownRenderer, ) { - @GetMapping("/teacher/courses/{courseId}/students/{studentId}") + @GetMapping("/teacher/courses/{courseNo}/students/{studentId}") fun teacherProfile( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable studentId: String, session: HttpSession, 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( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable studentId: String, session: HttpSession, model: Model, - ): String = renderProfile(courseId, studentId, session, model) + ): String = renderProfile(courseNo, studentId, session, model) private fun renderProfile( - courseId: String, + courseNo: String, studentId: String, session: HttpSession, model: Model, ): 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) 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("isAdmin", isAdmin) model.addAttribute("courseUrl", courseUrl) @@ -61,37 +64,39 @@ class StudentProfileController( model.addAttribute( "downloadBase", if (isAdmin) { - "/admin/courses/{courseId}/submissions/{id}/download" + "/admin/courses/{courseNo}/submissions/{id}/download" } else { - "/teacher/courses/{courseId}/submissions/{id}/download" + "/teacher/courses/{courseNo}/submissions/{id}/download" }, ) return "profile/student" } - @GetMapping("/teacher/courses/{courseId}/submissions/{id}/download") + @GetMapping("/teacher/courses/{courseNo}/submissions/{id}/download") fun teacherDownload( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @ModelAttribute form: ProfileSubmissionDownloadForm, session: HttpSession, - ): ResponseEntity = downloadSubmission(courseId, id, form, session) + ): ResponseEntity = downloadSubmission(courseNo, id, form, session) - @GetMapping("/admin/courses/{courseId}/submissions/{id}/download") + @GetMapping("/admin/courses/{courseNo}/submissions/{id}/download") fun adminDownload( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @ModelAttribute form: ProfileSubmissionDownloadForm, session: HttpSession, - ): ResponseEntity = downloadSubmission(courseId, id, form, session) + ): ResponseEntity = downloadSubmission(courseNo, id, form, session) private fun downloadSubmission( - courseId: String, + courseNo: String, id: String, form: ProfileSubmissionDownloadForm, session: HttpSession, ): ResponseEntity { - 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 encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8) return ResponseEntity diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt index 175fee8..9e2b6ba 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt @@ -15,7 +15,7 @@ import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.CourseGroupPayload import ru.ysu.lms.web.api.attachCourseGroup 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.courseGroups import ru.ysu.lms.web.api.detachCourseGroup @@ -49,24 +49,27 @@ class TeacherController( return "teacher/dashboard" } - @GetMapping("/courses/{id}") + @GetMapping("/courses/{courseNo}") fun course( - @PathVariable id: String, + @PathVariable courseNo: String, session: HttpSession, model: Model, ): String { 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 } // Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет 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("groups", groups) 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( "labs", - api.listLabs(token, id).map { lab -> + api.listLabs(token, course.id).map { lab -> LabRow( id = lab.id, number = lab.number, @@ -81,53 +84,56 @@ class TeacherController( return "teacher/course" } - @PostMapping("/courses/{id}/groups/attach") + @PostMapping("/courses/{courseNo}/groups/attach") fun attachGroup( - @PathVariable id: String, + @PathVariable courseNo: String, @RequestParam groupId: String, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = api.courseByUrlNo(token, courseNo) return try { 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:/teacher/courses/$id" + "redirect:/teacher/courses/${course.courseNo}" } catch (ex: ApiException) { 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( - @PathVariable id: String, + @PathVariable courseNo: String, @RequestParam groupId: String, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = api.courseByUrlNo(token, courseNo) return try { - api.detachCourseGroup(token, id, groupId) + api.detachCourseGroup(token, course.id, groupId) redirect.addFlashAttribute("notice", "Группа отвязана от курса") - "redirect:/teacher/courses/$id" + "redirect:/teacher/courses/${course.courseNo}" } catch (ex: ApiException) { 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( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable groupId: String, session: HttpSession, model: Model, ): String { val token = token(session) ?: return "redirect:/login" - val roster = api.courseGroupStudents(token, courseId, groupId) - model.addAttribute("courseId", courseId) + val course = api.courseByUrlNo(token, courseNo) + val roster = api.courseGroupStudents(token, course.id, groupId) + model.addAttribute("courseNo", course.courseNo) model.addAttribute("groupId", groupId) model.addAttribute("roster", roster) return "teacher/group" diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt index d141823..1f10024 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt @@ -12,10 +12,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes 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.CourseDetailDto 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.courseContentTree -import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.createCourseArticle import ru.ysu.lms.web.api.createCourseChapter import ru.ysu.lms.web.api.createCourseSection @@ -33,22 +34,23 @@ import ru.ysu.lms.web.session.SessionKeys * каждое действие — обычный POST с редиректом назад на страницу курса. */ @Controller -@RequestMapping("/teacher/courses/{courseId}/content") +@RequestMapping("/teacher/courses/{courseNo}/content") class TeacherCourseContentController( private val api: ApiClient, private val markdown: MarkdownRenderer, ) { @GetMapping fun content( - @PathVariable courseId: String, + @PathVariable courseNo: String, session: HttpSession, model: Model, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - model.addAttribute("course", api.courseDetail(token, courseId)) - model.addAttribute("tree", api.courseContentTree(token, courseId)) + model.addAttribute("course", course) + model.addAttribute("tree", api.courseContentTree(token, course.id)) // Рендерер отдаём в шаблон, чтобы предпросмотр статей всегда совпадал с тем, // что увидит студент (та же санитизация markdown). model.addAttribute("markdown", markdown) @@ -61,64 +63,67 @@ class TeacherCourseContentController( @PostMapping("/chapters") fun createChapter( - @PathVariable courseId: String, + @PathVariable courseNo: String, @RequestParam title: String, @RequestParam(required = false) slug: String?, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" 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()}» создана") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @PostMapping("/chapters/{id}/rename") fun renameChapter( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @RequestParam title: String, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.renameCourseChapter(token, courseId, id, title.trim()) + api.renameCourseChapter(token, course.id, id, title.trim()) redirect.addFlashAttribute("notice", "Глава переименована") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @PostMapping("/chapters/{id}/delete") fun deleteChapter( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.deleteCourseChapter(token, courseId, id) + api.deleteCourseChapter(token, course.id, id) redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @Suppress("LongParameterList") @PostMapping("/sections") fun createSection( - @PathVariable courseId: String, + @PathVariable courseNo: String, @RequestParam chapterId: String, @RequestParam title: String, @RequestParam(required = false) slug: String?, @@ -126,64 +131,67 @@ class TeacherCourseContentController( redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { api.createCourseSection( token, - courseId, + course.id, chapterId, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() }, ) redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @PostMapping("/sections/{id}/rename") fun renameSection( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @RequestParam title: String, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.renameCourseSection(token, courseId, id, title.trim()) + api.renameCourseSection(token, course.id, id, title.trim()) redirect.addFlashAttribute("notice", "Раздел переименован") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @PostMapping("/sections/{id}/delete") fun deleteSection( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @RequestParam(required = false) moveTo: String?, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.deleteCourseSection(token, courseId, id, moveTo) + api.deleteCourseSection(token, course.id, id, moveTo) redirect.addFlashAttribute("notice", "Раздел удалён") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @Suppress("LongParameterList") @PostMapping("/articles") fun createArticle( - @PathVariable courseId: String, + @PathVariable courseNo: String, @RequestParam sectionId: String, @RequestParam title: String, @RequestParam(defaultValue = "") bodyMd: String, @@ -191,10 +199,11 @@ class TeacherCourseContentController( redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { api.createCourseArticle( token, - courseId, + course.id, CreateArticlePayload( chapterId = null, sectionId = sectionId, @@ -205,17 +214,17 @@ class TeacherCourseContentController( ), ) redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @Suppress("LongParameterList") @GetMapping("/{chapter}/{section}/{article}") fun article( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable chapter: String, @PathVariable section: String, @PathVariable article: String, @@ -224,23 +233,24 @@ class TeacherCourseContentController( redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - val dto = api.courseContentArticle(token, courseId, chapter, section, article) - model.addAttribute("course", api.courseDetail(token, courseId)) + val dto = api.courseContentArticle(token, course.id, chapter, section, article) + model.addAttribute("course", course) model.addAttribute("article", dto) 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" } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectContent(courseId) + redirectContent(course.courseNo) } } @Suppress("LongParameterList") @PostMapping("/articles/{id}/save") fun saveArticle( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @RequestParam title: String, @RequestParam(defaultValue = "") bodyMd: String, @@ -249,39 +259,41 @@ class TeacherCourseContentController( redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.updateCourseArticle(token, courseId, id, title.trim(), bodyMd) + api.updateCourseArticle(token, course.id, id, title.trim(), bodyMd) redirect.addFlashAttribute("notice", "Статья сохранена") - redirectBack(back, contentPath(courseId)) + redirectBack(back, contentPath(course.courseNo)) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectBack(back, contentPath(courseId)) + redirectBack(back, contentPath(course.courseNo)) } } @PostMapping("/articles/{id}/delete") fun deleteArticle( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable id: String, @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" return try { - api.deleteCourseArticle(token, courseId, id) + api.deleteCourseArticle(token, course.id, id) redirect.addFlashAttribute("notice", "Статья удалена") - redirectContent(courseId) + redirectContent(course.courseNo) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectBack(back, contentPath(courseId)) + redirectBack(back, contentPath(course.courseNo)) } } @Suppress("LongParameterList") @PostMapping("/{kind}/{id}/published") fun published( - @PathVariable courseId: String, + @PathVariable courseNo: String, @PathVariable kind: String, @PathVariable id: String, @RequestParam published: Boolean, @@ -290,33 +302,47 @@ class TeacherCourseContentController( redirect: RedirectAttributes, ): String { val token = token(session) ?: return "redirect:/login" + val course = resolveCourse(token, courseNo, redirect) ?: return "redirect:/teacher" if (kind !in setOf("chapters", "sections", "articles")) { redirect.addFlashAttribute("error", "Неизвестный тип материала") - return redirectBack(back, contentPath(courseId)) + return redirectBack(back, contentPath(course.courseNo)) } return try { - api.setCourseContentPublished(token, courseId, kind, id, published) + api.setCourseContentPublished(token, course.id, kind, id, published) redirect.addFlashAttribute( "notice", if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", ) - redirectBack(back, contentPath(courseId)) + redirectBack(back, contentPath(course.courseNo)) } catch (ex: ApiException) { 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( - courseId: String, + courseNo: Long, chapter: String, section: 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( diff --git a/web/src/main/resources/templates/admin/course.html b/web/src/main/resources/templates/admin/course.html index 1ac9e2f..727f737 100644 --- a/web/src/main/resources/templates/admin/course.html +++ b/web/src/main/resources/templates/admin/course.html @@ -11,7 +11,7 @@ — -
@@ -37,13 +37,13 @@ - — — @@ -57,7 +57,7 @@ -
@@ -103,7 +103,7 @@
-
+ th:value="${profileCourseNo != null} ? @{/admin/groups/{id}/publish-student(id=${group.id}, courseNo=${profileCourseNo})} : @{/admin/groups/{id}/publish-student(id=${group.id})}">
@@ -60,14 +60,14 @@ —
- + @@ -81,7 +81,7 @@ ⤓0
@@ -124,10 +124,10 @@ - — -
—
+
—
- Профиль + Профиль
- + diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html index 94a0b9a..43ce9f9 100644 --- a/web/src/main/resources/templates/admin/labs.html +++ b/web/src/main/resources/templates/admin/labs.html @@ -7,10 +7,10 @@

Лабораторные

- @@ -37,7 +37,7 @@ 1 — - 1. Курс — diff --git a/web/src/main/resources/templates/profile/student.html b/web/src/main/resources/templates/profile/student.html index 2874253..d8c46d8 100644 --- a/web/src/main/resources/templates/profile/student.html +++ b/web/src/main/resources/templates/profile/student.html @@ -194,7 +194,7 @@ Скачать + th:href="@{${downloadBase}(courseNo=${profile.course.courseNo}, id=${submission.id}, studentNo=${profile.student.studentNo}, name=${profile.student.fullName}, lab=${lab.number}, attempt=${submission.seq})}">Скачать diff --git a/web/src/main/resources/templates/teacher/content-article.html b/web/src/main/resources/templates/teacher/content-article.html index 0d6c23d..08749c6 100644 --- a/web/src/main/resources/templates/teacher/content-article.html +++ b/web/src/main/resources/templates/teacher/content-article.html @@ -4,9 +4,9 @@