# 12. Отчёт этапа 2: academic (курсы и лабораторные) **Дата:** 2026-10-27. **Статус:** этап 2 выполнен; этап 3 (доступ и профиль студента курса) — следующий. ## 1. Что сделано ### Курсы - Добавлены таблицы `courses`, `course_teachers`, `course_groups` (миграция `V6`). - `courses.course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный номер для человекочитаемых URL; UUID остаётся первичным ключом. - CRUD курсов: создание, просмотр, переименование, перевод в архив и возврат в работу. Название нормализуется (trim), пустое заменяется на «Новый курс». - Название курса уникально без учёта регистра (миграция `V10`, индекс по `lower(title)`); повторное создание возвращает `409`. - Назначение и снятие преподавателей, привязка и отвязка групп хранятся с историей: снятие/отвязка проставляют `removed_at`, запись не удаляется. Повторное назначение после снятия создаёт новую активную запись. - Для архивированного курса запрещены новые назначения преподавателей и привязка групп. ### Bootstrap курса C - `CourseBootstrapService` идемпотентно создаёт (или находит) курс «Промышленное программирование на языке C», назначает владельцем текущего админа и переносит в него существующие данные. - Конкурентные запуски сериализуются advisory-локом `pg_advisory_xact_lock(hashtext('academic.course.bootstrap'))`. - Перед переносом проверяются дубли номеров лабораторных без курса. При дублях bootstrap завершается `409`, транзакция откатывается, курс не создаётся. - Все активные группы, переданные из web, привязываются к курсу (повторный запуск не создаёт дублей). - Всем лабораторным и главам без курса проставляется `course_id = C`; у курсовых глав `audience` сбрасывается в `NULL`. - После переноса, если лабораторных без курса не осталось, сервис в рантайме выполняет `ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL`. Это **post-bootstrap шаг, а не Flyway-файл**: обычный старт Flyway не может дождаться создания курса и назначения владельца. ### Лабораторные - `labs.course_id` добавлен nullable-миграцией `V7`; уникальность номера — в рамках курса: `(number, course_id)`. - Создание и импорт лабораторной требуют курс; архивированный курс отклоняется с `400`. - Список лабораторных фильтруется по `courseId`; API и UI показывают номер и название курса у каждой лабораторной. - Копирование лабораторной в другой курс (`LabCopyService`): копируются описание, уровни и варианты с новыми id, копия создаётся черновиком. Оценки, сдачи, защиты и назначения вариантов не копируются. - Копирование запрещено в тот же курс и в архивированный курс; занятый номер в целевом курсе даёт `409`. ### Админ-UI - `/admin/courses` — список курсов с номером, названием, статусом и счётчиками преподавателей, групп и лабораторных; создание курса; блок первичного bootstrap, пока курсов нет. - `/admin/courses/{id}` — карточка курса: архивирование/возврат, привязка и отвязка групп, назначение и снятие преподавателей по UUID аккаунта, список лабораторных курса. - `/admin/labs` — фильтр по курсу, колонка курса, выбор курса (обязательный) в формах импорта и создания. - `/admin/labs/{id}` — карточка лабораторной показывает курс; кнопка «Копировать в курс» открывает форму выбора целевого курса, номера и названия. - В навигацию админа добавлен пункт «Курсы». ### Миграции - `V6__courses.sql` — курсы, преподаватели, группы курса. - `V7__labs_course.sql` — `labs.course_id`, уникальность `(number, course_id)`. - `V8__chapters_course.sql` — `chapters.course_id/audience`, частичные индексы slug. - `V9__article_assets_indexes.sql` — индекс `article_assets(article_id)`. - `V10__course_title_unique.sql` — уникальный индекс `lower(title)` по курсам. ## 2. API Все эндпоинты курсов доступны только роли `ADMIN`. | Метод | Путь | Назначение | |---|---|---| | `GET` | `/api/v1/courses` | список курсов со счётчиками | | `POST` | `/api/v1/courses` | создать курс, владелец — текущий админ | | `GET` | `/api/v1/courses/{id}` | карточка курса с историей преподавателей и групп | | `PUT` | `/api/v1/courses/{id}` | изменить название и/или статус | | `POST` | `/api/v1/courses/{id}/teachers` | назначить преподавателя | | `DELETE` | `/api/v1/courses/{id}/teachers/{accountId}` | снять преподавателя | | `POST` | `/api/v1/courses/{id}/groups` | привязать группу | | `DELETE` | `/api/v1/courses/{id}/groups/{groupId}` | отвязать группу | | `POST` | `/api/v1/courses/bootstrap` | создать курс C и перенести данные | | `GET` | `/api/v1/labs?courseId=…` | список лабораторных, опционально по курсу | | `POST` | `/api/v1/labs` | создать лабораторную с обязательным `courseId` | | `POST` | `/api/v1/labs/import?courseId=…` | импорт ZIP в указанный курс | | `POST` | `/api/v1/labs/{id}/copy` | копировать лабораторную в другой курс | `LabSummary` и `LabDetail` дополнены полями `courseId`, `courseNo`, `courseTitle`. В браузерных URL курса используется `course_no`; API работает с UUID. ## 3. Порядок bootstrap 1. Админ открывает `/admin/courses`. Пока курсов нет, показывается блок первичной настройки. 2. Web передаёт в `POST /api/v1/courses/bootstrap` название (необязательно) и id всех активных групп. 3. Сервис берёт advisory-лок, проверяет UUID групп, находит или создаёт курс по названию без учёта регистра (владелец — текущий админ). 4. Привязывает активные группы, проверяет дубли номеров лабораторных без курса. 5. Проставляет `course_id` лабораторным и главам. 6. Если лабораторных без курса не осталось, выполняет `SET NOT NULL` для `labs.course_id`. 7. Возвращает `courseId`, `courseNo`, название и счётчики: сколько групп привязано, лабораторных и глав перенесено, сколько осталось без курса. 8. Повторный запуск безопасен: существующий курс переиспользуется, уже привязанные группы и перенесённые данные не дублируются. ## 4. Тесты Добавлены интеграционные тесты на PostgreSQL (Testcontainers): - `CourseServiceIntegrationTest` (6 тестов): отказ bootstrap при дублях номеров без создания курса, создание курса и перенос групп/лабораторных/глав, идемпотентность bootstrap, генерация уникальных `course_no`, запрет дубликата названия без учёта регистра, история назначений преподавателей и групп, отказ при некорректном UUID группы. - `LabCopyIntegrationTest` (5 тестов): копирование с уровнями и вариантами, обязательный курс при создании, запрет копирования в тот же курс, в архивированный курс и создание лабораторной в архивированном курсе. Прогон 2026-10-27: `./gradlew :services:academic-service:test :web:test` — успешно, в новых наборах 0 failures / 0 errors. `git diff --check` — успешно. ## 5. Известные пробелы (этап 3) - **Материалы курса и аудитории.** Схема `chapters.course_id/audience` готова (`V8`), но URL `/learn/course/{course_no}/...` и проверка аудитории ещё не реализованы: читалка материалов пока работает по прежним глобальным правилам. - **Активы.** `V9` добавляет только индекс; наследуемая видимость активов от статьи → раздела → главы и проверка доступа при выдаче файлов — этап 3. - **Проверка аккаунтов преподавателей.** Academic принимает UUID без обращения к identity: не проверяются существование аккаунта, роль `TEACHER` и статус `ACTIVE`. Нужна внутренняя проверка identity при назначении. - **Кабинет преподавателя и скоупинг.** API курсов пока admin-only; кабинет `/teacher/...`, доступ преподавателя только к своим курсам и студентам, профиль студента курса и negative-тесты «чужой курс/студент/сдача» — этап 3. - **Повторный bootstrap из UI.** Кнопка bootstrap показывается только когда курсов нет; повторный запуск возможен через API. Группы, появившиеся позже, привязываются вручную на странице курса.