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

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

View File

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
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` с фильтром по действию,

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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`,

View File

@@ -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 с) зелёные.

View File

@@ -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-активов и

View File

@@ -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 с.

View File

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

View File

@@ -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, контракты, импорт,

View File

@@ -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 зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы».

View File

@@ -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."
}
]
}

View File

@@ -155,23 +155,23 @@ test("teacher opens course, gradebook, student profile and authors a chapter", a
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await 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);
});

View File

@@ -21,7 +21,7 @@ import ru.ysu.lms.web.api.CourseUpdatePayload
import ru.ysu.lms.web.api.assignCourseTeacher
import ru.ysu.lms.web.api.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 =

View File

@@ -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)
}
}

View File

@@ -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)
}
/** Массовая установка уровня выделенным студентам. */

View File

@@ -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<StudentRowDto> {
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<ByteArray> {
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<String, Any> =
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 =

View File

@@ -26,6 +26,7 @@ import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.addLevel
import ru.ysu.lms.web.api.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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -14,6 +14,7 @@ import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.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<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(
@PathVariable courseId: String,
@PathVariable courseNo: String,
@PathVariable id: String,
@ModelAttribute form: ProfileSubmissionDownloadForm,
session: HttpSession,
): ResponseEntity<ByteArray> = downloadSubmission(courseId, id, form, session)
): ResponseEntity<ByteArray> = downloadSubmission(courseNo, id, form, session)
private fun downloadSubmission(
courseId: String,
courseNo: String,
id: String,
form: ProfileSubmissionDownloadForm,
session: HttpSession,
): 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 encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
return ResponseEntity

View File

@@ -15,7 +15,7 @@ import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseGroupPayload
import ru.ysu.lms.web.api.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"

View File

@@ -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(

View File

@@ -11,7 +11,7 @@
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
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' ? 'Переместить курс в архив? Привязка групп и преподавателей станет недоступна.' : 'Вернуть курс в работу?'}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="status" th:value="${course.status == 'ACTIVE' ? 'ARCHIVED' : 'ACTIVE'}">
@@ -37,13 +37,13 @@
<tbody>
<tr th:each="attached : ${attachedGroups}">
<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>
</td>
<td class="text-muted" th:text="${attached.addedAt}">—</td>
<td class="text-center">
<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="Отвязать группу от курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отвязать</button>
@@ -57,7 +57,7 @@
</table>
</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">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
@@ -89,7 +89,7 @@
<td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center">
<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="Снять преподавателя с курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Снять</button>
@@ -103,7 +103,7 @@
</table>
</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">
<div><label class="label" for="teacherAccount">Преподаватель</label>
<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">
<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 class="card card-flush">
<table>

View File

@@ -45,7 +45,7 @@
<tbody>
<tr th:each="course : ${courses}">
<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' ? 'активен' : 'в архиве'}">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
@@ -55,7 +55,7 @@
<td th:text="${course.groupCount}">0</td>
<td th:text="${course.labCount}">0</td>
<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>
</tr>
<tr th:if="${#lists.isEmpty(courses)}"><td colspan="7" class="empty">Курсов пока нет.</td></tr>

View File

@@ -46,7 +46,7 @@
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<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 class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x">
@@ -60,14 +60,14 @@
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<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="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button>
<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">
<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 class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'">
@@ -81,7 +81,7 @@
</button>
</form>
<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>
</div>
</div>
@@ -124,10 +124,10 @@
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<a class="student-name" th:if="${profileCourseId != 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 class="student-name" th:if="${profileCourseNo != null}"
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>
<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">
<span class="password-dot" th:if="${!teacherMode}" th:classappend="${row.mustChangePassword} ? 'warn' : 'ok'"
th:title="${row.mustChangePassword} ? 'Системный пароль — требуется смена' : 'Пароль изменён'"
@@ -196,8 +196,8 @@
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<a class="btn btn-secondary btn-sm" th:if="${profileCourseId != 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>
<a class="btn btn-secondary btn-sm" th:if="${profileCourseNo != null}"
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="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
@@ -258,7 +258,7 @@
<div class="dlg-body">
<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="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="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
@@ -333,7 +333,7 @@
<form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<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="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level">
@@ -342,7 +342,7 @@
<form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<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="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id">
@@ -360,7 +360,7 @@
<div class="dlg-body">
<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="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}">
<div><label class="label" for="defense-student-select">Студент</label>
<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>
<form class="inline-form" id="defense-issue-form" method="post">
<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="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<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="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button>

View File

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

View File

@@ -194,7 +194,7 @@
</td>
<td class="text-right">
<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>
</tr>
</tbody>

View File

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

View File

@@ -3,7 +3,7 @@
<head><title th:text="${course.title} + ' — материалы — LMS'">Материалы курса</title></head>
<body>
<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>
@@ -21,7 +21,7 @@
</div>
<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}">
<h2 class="section-title m-0">Новая глава</h2>
<div>
@@ -51,17 +51,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<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"
th:text="${chapter.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</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="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -91,17 +91,17 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!section.published}">
<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"
th:text="${section.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</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="Удалить раздел со всеми статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -120,28 +120,28 @@
<ul class="sort-list text-sm mt-2">
<li th:each="article : ${section.articles}">
<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>
<span class="badge"
th:classappend="${article.published ? 'badge-ok' : 'badge-warn'}"
th:text="${article.published ? 'опубликовано' : 'скрыто'}">—</span>
<div class="sort-actions">
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!article.published}">
<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"
th:text="${article.published ? 'Скрыть' : 'Опубликовать'}">Скрыть</button>
</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="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</form>
</div>
@@ -150,7 +150,7 @@
</ul>
<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="sectionId" th:value="${section.id}">
<div>
@@ -167,7 +167,7 @@
</div>
<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="chapterId" th:value="${chapter.id}">
<div>

View File

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

View File

@@ -11,7 +11,7 @@
<div class="card h-100">
<div class="card-body">
<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>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
@@ -23,7 +23,7 @@
</div>
<div class="mt-4">
<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>

View File

@@ -3,7 +3,7 @@
<head><title th:text="${(#strings.isEmpty(roster.groupName) ? groupId : roster.groupName)} + ' — LMS'">Группа</title></head>
<body>
<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>
@@ -33,7 +33,7 @@
<tr th:each="student : ${roster.students}">
<td>
<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>
</td>
<td class="font-mono" th:text="${student.studentNo}">—</td>
@@ -44,7 +44,7 @@
</td>
<td class="text-right">
<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>
</tr>
<tr th:if="${#lists.isEmpty(roster.students)}">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,10 +51,13 @@ class StudentProfileTemplateTest {
assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время")
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("attempt=2")
assertThat(html).contains("← Все курсы")
assertThat(html).contains("href=\"/admin/courses/1\"")
assertThat(html).doesNotContain("/admin/courses/c1")
assertThat(html).doesNotContain("/courses/c1/")
}
@Test
@@ -62,7 +65,10 @@ class StudentProfileTemplateTest {
val html = render(isAdmin = false)
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 {
@@ -76,16 +82,16 @@ class StudentProfileTemplateTest {
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("profile", sampleProfile())
context.setVariable("isAdmin", isAdmin)
context.setVariable("courseUrl", if (isAdmin) "/admin/courses/c1" else "/teacher/courses/c1")
context.setVariable("backUrl", 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/1" else "/teacher/courses/1")
context.setVariable("backLabel", if (isAdmin) "← Все курсы" else "← Мои курсы")
context.setVariable("backTitle", "Вернуться к курсу")
context.setVariable(
"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 engine.process("profile/student", context)

View File

@@ -12,15 +12,18 @@ import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.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.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
import ru.ysu.lms.web.api.CourseGroupDto
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.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.listLabs
@@ -38,7 +41,7 @@ class TeacherControllerCourseGroupsTest {
@BeforeEach
fun setUp() {
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(
id = "c1",
courseNo = 1,
@@ -84,9 +87,26 @@ class TeacherControllerCourseGroupsTest {
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() =
mockMvc
.perform(get("/teacher/courses/c1").session(session()))
.perform(get("/teacher/courses/1").session(session()))
.andReturn()
.modelAndView!!
.model

View File

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

View File

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

View File

@@ -35,16 +35,27 @@ class TeacherCourseTemplateTest {
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("/admin/groups/g1")
assertThat(html).contains("courseId=c1")
assertThat(html).contains("courseNo=1")
assertThat(html).doesNotContain("courseId")
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
fun `renders group attach form with available groups`() {
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("ПИ-22БО")
assertThat(html).contains("Привязать")

View File

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