Files
magistr/docs/API.md

71 KiB
Raw Permalink Blame History

🔌 REST API

Все прикладные эндпоинты имеют префикс /api/. Служебные проверки Kubernetes доступны под /actuator/health/. Ответы возвращаются в формате JSON.

Необработанные ошибки проходят через единый GlobalExceptionHandler. Для 400, 404, 409 и 500 используется общий JSON-формат:

{
  "timestamp": "2026-05-27T16:47:54Z",
  "status": 400,
  "error": "Некорректный запрос",
  "message": "Некорректные параметры запроса",
  "path": "/api/schedule"
}

Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем message.

Все поля момента времени (createdAt, updatedAt, reviewedAt, archivedAt и аналогичные) сериализуются как ISO-8601 UTC с суффиксом Z. Поля календарной даты (date, validFrom, validTo, activeFrom, activeTo) остаются строками YYYY-MM-DD без часового пояса и вычисляются по бизнес-зоне Europe/Moscow.

Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK возвращают 400, а UNIQUE, FK и GiST exclusion conflicts — 409 с безопасным русским сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не передаются; неизвестное нарушение получает обобщённое сообщение.


Служебные проверки состояния

Эти endpoints предназначены для Kubernetes kubelet, не требуют bearer-токен и не зависят от tenant-домена в заголовке Host. Из Actuator наружу опубликован только health, а состав компонентов, домены, JDBC URL и credentials в ответах скрыты.

GET /actuator/health/liveness

Проверяет только жизнеспособность процесса. Недоступность tenant-БД не меняет liveness и не должна создавать цикл перезапусков pod.

Ответ работающего процесса (200):

{
  "status": "UP"
}

GET /actuator/health/readiness

Разрешает направлять трафик в pod только после успешных миграций и свежей успешной проверки соединения со всеми обязательными tenant-БД. Пустая конфигурация, H2-заглушка, ошибка чтения tenant-конфигурации, незавершённая или неуспешная миграция, недоступное либо просроченное соединение делают pod неготовым.

Готов (200):

{
  "status": "UP"
}

Не готов (503):

{
  "status": "DOWN"
}

UP и DOWN — стандартные машинные значения протокола Spring Boot Actuator, а не пользовательские сообщения интерфейса.


Аутентификация

POST /api/auth/login

Вход в систему.

Тело запроса:

{
  "username": "admin",
  "password": "admin"
}

Успешный ответ (200):

{
  "success": true,
  "message": "OK",
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "role": "ADMIN",
  "redirect": "/admin/",
  "departmentId": 1,
  "userId": 1
}

Ответ также устанавливает HttpOnly cookie magistr_refresh для обновления access-токена.

Ошибка (401):

{
  "success": false,
  "message": "Неверное имя пользователя или пароль",
  "token": null,
  "role": null,
  "redirect": null,
  "departmentId": null,
  "userId": null
}

Для несуществующего пользователя, неверного пароля и архивной учётной записи возвращается одинаковый ответ 401, поэтому endpoint не раскрывает наличие или состояние пользователя.

Временная блокировка (429):

HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
  "success": false,
  "message": "Слишком много попыток входа. Повторите позже",
  "token": null,
  "role": null,
  "redirect": null,
  "departmentId": null,
  "userId": null
}

Backend ведёт общий для всех pod счётчик по tenant, нормализованному username и IP. После пяти отказов по умолчанию комбинация блокируется на 60 секунд; следующие отказы после окончания блокировки увеличивают задержку до максимума 15 минут. Retry-After содержит оставшееся целое число секунд. Успешный вход сбрасывает счётчик.

После получения access JWT клиент должен передавать его в заголовке: Authorization: Bearer <token>. Штатный web-клиент хранит access JWT только в памяти страницы; после reload он получает новый access JWT через HttpOnly refresh-cookie и POST /api/auth/refresh.

Поддерживаемые роли: ADMIN, EDUCATION_OFFICE, DEPARTMENT, SCHEDULE_VIEWER, TEACHER, STUDENT.

Redirect по ролям:

Роль Redirect
ADMIN /admin/
EDUCATION_OFFICE /admin/#schedule-view
DEPARTMENT /admin/#department-workspace
SCHEDULE_VIEWER /admin/#schedule-view
TEACHER /teacher/
STUDENT /student/

POST /api/auth/refresh

Обновляет access JWT по refresh-cookie. Тело запроса не требуется.

Успешный ответ (200):

{
  "success": true,
  "message": "OK",
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "role": "ADMIN",
  "redirect": "/admin/",
  "departmentId": 1,
  "userId": 1
}

Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается. Один refresh-токен можно успешно использовать только один раз, в том числе при параллельных запросах: первый запрос получает новую пару токенов, остальные получают 401, а их cookie очищается.

POST /api/auth/logout

Отзывает текущий refresh-токен и очищает refresh-cookie.

Успешный ответ (200):

{
  "success": true,
  "message": "Выход выполнен"
}

GET /api/auth/me

Возвращает текущего пользователя по bearer-токену.

{
  "userId": 1,
  "username": "admin",
  "role": "ADMIN",
  "departmentId": 1
}

departmentId присутствует в ответе всегда, но может быть null для пользователей без привязки к кафедре.


Пользователи

GET /api/users

Список всех пользователей.

Ответ:

[
  { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
  { "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
]

UserResponse единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля departmentName, departmentId и status не выводятся только если равны null.

GET /api/users/teachers

Список только преподавателей (роль TEACHER).

GET /api/users/teachers/{departmentId}

Список преподавателей привязанных к конкретной кафедре (роль TEACHER, код кафедры departmentId). Ответ использует ту же структуру UserResponse, что и GET /api/users.

Выборка использует записи teacher_department_assignments, действующие на текущую дату. Дополнительная связь (is_primary=false) также включает преподавателя в список кафедры. Роль DEPARTMENT может запрашивать только свою кафедру.

POST /api/users

Создание пользователя.

Тело запроса:

{
  "username": "teacher1",
  "password": "password",
  "role": "TEACHER",
  "fullName": "Test Teacher",
  "jobTitle": "Proffessor",
  "departmentId": 1
}

Валидация:

  • username — обязателен и уникален
  • password — минимум 8 символов
  • roleADMIN, EDUCATION_OFFICE, DEPARTMENT, SCHEDULE_VIEWER, TEACHER или STUDENT
  • fullName — обязателен
  • departmentId — обязателен

DELETE /api/users/{id}

Архивирование пользователя. Исторические связи и расписание остаются в БД, но пользователь больше не может войти.

POST /api/users/{id}/restore

Восстановление архивного пользователя.

GET /api/users/{id}/department-history

История переводов преподавателя между кафедрами.

POST /api/users/{id}/department-transfer

Перевод преподавателя на другую кафедру без потери прошлых связей.

{
  "departmentId": 2,
  "validFrom": "2026-06-01",
  "comment": "Перевод на кафедру ВТ"
}

Если validFrom находится в будущем, текущая основная кафедра остаётся действующей до дня, предшествующего переводу. Поле совместимости users.department_id переключается только когда новая основная запись уже действует на текущую дату. Пересекающиеся периоды двух основных кафедр одного преподавателя отклоняются с 409 Conflict.

GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01

Список преподавателей кафедры на конкретную дату по таблице истории назначений. Архивный преподаватель входит в исторический ответ, если на указанную дату действовали и пользователь, и его назначение. Роль DEPARTMENT может запрашивать только свою кафедру.


Заявки на создание преподавателей

Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.

Метод URL Назначение
GET /api/teacher-requests?status=PENDING Список заявок, опционально с фильтром статуса
POST /api/teacher-requests/{id}/approve Одобрить заявку, скорректировать данные и создать преподавателя
POST /api/teacher-requests/{id}/reject Отклонить заявку

Тело одобрения:

{
  "departmentId": 2,
  "username": "teacher.new",
  "password": "secure-pass",
  "fullName": "Новый Преподаватель",
  "jobTitle": "Доцент",
  "reviewComment": "Данные проверены"
}

Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.

Тело отклонения:

{
  "reviewComment": "Нужно уточнить ФИО"
}

Права ролей на API

Скрытие вкладок во frontend не является защитой. Все /api/** запросы, кроме POST /api/auth/login, POST /api/auth/refresh и POST /api/auth/logout, проходят через bearer access JWT и @RequireRoles.

Важные ограничения:

  • DEPARTMENT не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API;
  • DEPARTMENT создаёт и комментирует дисциплины через /api/department/*, где кафедра берётся из текущего пользователя;
  • /api/teacher-subjects для DEPARTMENT разрешает связывать только дисциплины своей кафедры и преподавателей с основной или дополнительной связью с ней на текущую дату;
  • SCHEDULE_VIEWER имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности.

Динамическое расписание

Новая модель расписания строится из правил (schedule_rules) и слотов (schedule_rule_slots). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.

GET /api/schedule

Получение расписания группы или преподавателя за период.

Параметры:

Параметр Обязателен Описание
groupId Да, если нет teacherId ID учебной группы
teacherId Да, если нет groupId ID преподавателя
startDate Да Начало периода в формате YYYY-MM-DD
endDate Да Конец периода в формате YYYY-MM-DD

Передаётся ровно один параметр: groupId или teacherId. Максимальный диапазон — 120 календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и отклоняется. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из /api/admin/time-slots.

Пример:

GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03

Ответ:

[
  {
    "scheduleRuleId": 10,
    "scheduleRuleSlotId": 31,
    "date": "2026-04-27",
    "dayOfWeek": 1,
    "dayName": "Понедельник",
    "weekNumber": 13,
    "parity": "ODD",
    "timeSlotId": 3,
    "timeSlotOrder": 3,
    "startTime": "11:40:00",
    "endTime": "13:10:00",
    "subjectId": 1,
    "subjectName": "Высшая математика",
    "teacherId": 2,
    "teacherName": "Петров Препод Петрович",
    "classroomId": 1,
    "classroomName": "101 Ленинская",
    "lessonTypeId": 1,
    "lessonTypeName": "Лекция",
    "lessonFormat": "Очно",
    "subgroupId": null,
    "subgroupName": null,
    "subgroupIds": [],
    "subgroupNames": [],
    "groupIds": [1],
    "groupNames": ["ИВТ-21-1"],
    "activityType": "Т",
    "lessonTypeAcademicHours": 32,
    "consumedLessonTypeAcademicHoursBeforeLesson": 12,
    "remainingLessonTypeAcademicHoursAfterLesson": 18
  }
]

GET /api/schedule/semesters

Доступный только для чтения список семестров для фильтров просмотра расписания. Доступен всем ролям, которые могут просматривать расписание, включая DEPARTMENT и SCHEDULE_VIEWER. Семестры возвращаются от новых к старым.

[
  {
    "id": 2,
    "academicYearId": 1,
    "academicYearTitle": "2025/2026",
    "semesterType": "spring",
    "startDate": "2026-02-09",
    "endDate": "2026-06-30"
  }
]

GET /api/admin/time-slots

Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.

Метод URL Назначение
GET /api/admin/time-slots Список слотов
GET /api/admin/time-slots/effective?date=2026-05-02 Эффективные слоты для даты
POST /api/admin/time-slots Создать слот
PUT /api/admin/time-slots/{id} Обновить слот
DELETE /api/admin/time-slots/{id} Удалить слот
GET /api/admin/time-slots/scopes Список сеток времени
POST /api/admin/time-slots/scopes Создать ручную сетку
PUT /api/admin/time-slots/scopes/{id} Переименовать ручную сетку
DELETE /api/admin/time-slots/scopes/{id} Удалить ручную сетку
GET /api/admin/time-slots/date-assignments Ручные назначения дат
POST /api/admin/time-slots/date-assignments Применить ручную сетку к дате
DELETE /api/admin/time-slots/date-assignments/{id} Убрать ручное назначение

Тело создания/обновления:

{
  "orderNumber": 1,
  "scopeId": 1,
  "startTime": "08:00:00",
  "endTime": "09:30:00"
}

durationMinutes в запросе не требуется и не считается доверенным значением: backend всегда вычисляет длительность как разницу endTime - startTime и возвращает результат в ответе. Начало должно быть раньше окончания, а длительность — не меньше одной минуты. Внутри одной сетки запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы; соседние слоты, у которых окончание первого совпадает с началом второго, разрешены. Конфликт номера или интервала возвращает 409 Conflict. Слот, на который уже ссылается правило расписания, нельзя перенести из базовой сетки DEFAULT.

Создание ручной сетки:

{
  "name": "Праздничная сетка"
}

Ручное применение сетки к дате:

{
  "date": "2026-05-08",
  "scopeId": 3
}

Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из date-assignments, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.

Учебные годы, семестры и коды календарного графика

Метод URL Назначение
GET /api/admin/calendar/years Учебные годы с семестрами
POST /api/admin/calendar/years Создать учебный год
PUT /api/admin/calendar/years/{id} Обновить учебный год
DELETE /api/admin/calendar/years/{id} Удалить учебный год
GET /api/admin/calendar/years/{academicYearId}/semesters Семестры учебного года
POST /api/admin/calendar/years/{academicYearId}/semesters Создать семестр
PUT /api/admin/calendar/semesters/{id} Обновить семестр
GET /api/admin/calendar/activity-types Справочник кодов активностей графика
POST /api/admin/calendar/activity-types Создать код активности
PUT /api/admin/calendar/activity-types/{id} Обновить код активности
DELETE /api/admin/calendar/activity-types/{id} Удалить код активности

Тело учебного года:

{
  "title": "2026-2027",
  "startDate": "2026-09-01",
  "endDate": "2027-06-30"
}

Тело семестра:

{
  "semesterType": "autumn",
  "startDate": "2026-09-01",
  "endDate": "2027-01-31"
}

Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий период разрешено начать на следующий календарный день после окончания предыдущего. Повторное название года, повторный тип семестра или пересечение возвращаются как 409 Conflict; неверный порядок дат и выход семестра за границы года — как 400 Bad Request. При сужении учебного года все существующие семестры должны оставаться внутри новых границ.

Тело создания/обновления кода активности:

{
  "code": "Т",
  "name": "Теоретическое обучение",
  "allowSchedule": true,
  "colorCode": "#22c55e",
  "displayOrder": 10,
  "description": "Обычные пары разрешены"
}

Календарные учебные графики

Метод URL Назначение
GET /api/admin/academic-calendars Список графиков, фильтры academicYearId, specialtyId, profileId
GET /api/admin/academic-calendars/{id} Один график
POST /api/admin/academic-calendars Создать график
PUT /api/admin/academic-calendars/{id} Обновить график
DELETE /api/admin/academic-calendars/{id} Удалить график
GET /api/admin/academic-calendars/{id}/grid Дневная сетка графика
PUT /api/admin/academic-calendars/{id}/grid Полное сохранение дневной сетки
GET /api/admin/academic-calendars/{id}/subjects Дисциплины графика по номерам учебных семестров
PUT /api/admin/academic-calendars/{id}/subjects Полная замена привязок дисциплин графика

Тело создания/обновления графика:

{
  "academicYearId": 1,
  "specialtyId": 2,
  "specialtyProfileId": 3,
  "studyFormId": 1,
  "courseCount": 4
}

studyFormId берётся из общего справочника форм обучения GET /api/education-forms; отдельного справочника форм для календарных графиков нет. courseCount должен быть в диапазоне 1..8. Поле title в запросе не требуется и не влияет на результат: backend всегда формирует название автоматически как код специальности — профиль обучения — форма обучения — учебный год. Отдельного пользовательского названия у календарного графика нет.

При PUT /api/admin/academic-calendars/{id} backend блокирует график и повторно проверяет все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает 409 Conflict с русским сообщением и не изменяет график. В частности, нельзя уменьшить courseCount, пока существуют строки старших курсов или дисциплины семестров выше courseCount * 2.

Ячейка сетки графика:

{
  "calendarId": 1,
  "courseNumber": 1,
  "date": "2025-09-01",
  "weekNumber": 1,
  "dayOfWeek": 1,
  "activityTypeId": 1
}

PUT /api/admin/academic-calendars/{id}/grid выполняет атомарную полную замену. До удаления прежних строк backend проверяет весь список: он должен быть непустым и не содержать null, курс должен входить в 1..courseCount, дата — в учебный год, dayOfWeek — совпадать с ISO-днём даты, а weekNumberс номером семидневного периода понедельник–воскресенье. Неделя 1 содержит дату начала учебного года; дни этой недели до начала года не входят в JSON и отображаются пустыми ячейками. Ключ (courseNumber, date) не должен повторяться, каждый activityTypeId или activityCode должен существовать. При любом 400 старая сетка остаётся без изменений; calendarId из строки не переопределяет ID в URL.

Привязка дисциплин к графику:

[
  { "semesterNumber": 1, "subjectId": 1 },
  { "semesterNumber": 1, "subjectId": 2 },
  { "semesterNumber": 2, "subjectId": 3 }
]

semesterNumber — номер учебного семестра внутри графика: для 4 курсов доступны значения 1..8. API принимает только существующие неархивные дисциплины из /api/subjects, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с subjectName, subjectCode и departmentId. PUT /subjects выполняет атомарную полную замену списка: перед вставкой нового набора старые привязки этого графика удаляются и синхронизируются с БД.

POST /api/admin/schedule-rules

Создание правила динамического расписания.

{
  "subjectId": 1,
  "semesterId": 1,
  "lectureAcademicHours": 32,
  "laboratoryAcademicHours": 16,
  "practiceAcademicHours": 24,
  "lectureStartWeek": 1,
  "laboratoryStartWeek": 3,
  "practiceStartWeek": 2,
  "groupIds": [1, 2],
  "slots": [
    {
      "dayOfWeek": 1,
      "parity": "BOTH",
      "timeSlotId": 3,
      "subgroupId": null,
      "subgroupIds": [],
      "teacherId": 2,
      "classroomId": 1,
      "lessonTypeId": 1,
      "lessonFormat": "Очно"
    }
  ]
}

CRUD доступен по:

Метод URL Назначение
GET /api/admin/schedule-rules Список правил, фильтры semesterId, groupId
GET /api/admin/schedule-rules/{id} Одно правило
POST /api/admin/schedule-rules Создать правило
PUT /api/admin/schedule-rules/{id} Обновить правило
DELETE /api/admin/schedule-rules/{id} Архивировать правило

timeSlotId должен ссылаться на базовый слот (scopeApplyMode = DEFAULT). Субботняя и ручные сетки не выбираются в правиле напрямую.

subgroupIds можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например [10, 22]. Для совместимости одиночный subgroupId тоже принимается, но новый формат — subgroupIds. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.

Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит часов обязателен, неотрицателен и кратен двум; ноль разрешён для неиспользуемого типа, но суммарно хотя бы один тип должен иметь положительный лимит. Если лимит типа ненулевой, в правиле должен быть хотя бы один слот этого типа; слот типа не принимается при нулевом лимите. Вложенные идентификаторы, которых нет в БД, считаются ошибкой payload и дают 400.

teacherId должен ссылаться на активного пользователя с ролью TEACHER, а lessonFormat принимает только Очно или Онлайн. parity принимает только BOTH, ODD или EVEN.

До сохранения backend попарно проверяет все слоты нового payload: точные дубли и пересечения преподавателя, аудитории или аудитории обучающихся отклоняются. ODD и EVEN не пересекаются; BOTH пересекается с обеими чётностями. Затем выполняется та же проверка с активными правилами семестра. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. Создание правил одного семестра сериализуется блокировкой строки семестра в PostgreSQL. Конфликт с уже сохранённым правилом возвращает 409 Conflict:

{
  "message": "Невозможно сохранить правило: слот занят",
  "conflictRule": {
    "id": 12,
    "subjectName": "Математический анализ",
    "semesterId": 1,
    "groupNames": ["ИБ-101"],
    "slots": []
  },
  "conflictFields": ["classroom"],
  "conflictReasons": ["Аудитория"]
}

conflictFields содержит технические причины пересечения: teacher, classroom и/или group. Для точного дубля используется slot. При конфликте внутри нового payload conflictRule отсутствует, потому что конфликтующей сохранённой записи ещё нет:

{
  "message": "Невозможно сохранить правило: слоты внутри правила конфликтуют",
  "conflictFields": ["teacher", "group"],
  "conflictReasons": ["Преподаватель", "Группа"]
}

conflictReasons содержит те же причины в русских подписях для интерфейса.

GET /api/lesson-types

Справочник типов занятий для конструктора правил расписания.

Ответ:

[
  { "id": 1, "name": "Лекция" },
  { "id": 2, "name": "Практика" },
  { "id": 3, "name": "Лабораторная работа" }
]

GET /api/schedule/search

Расширенный поиск расписания. В отличие от GET /api/schedule, принимает несколько фильтров одновременно.

Параметр Описание
startDate / endDate Обязательный период
groupId Учебная группа
teacherId Преподаватель
classroomId Аудитория
departmentId Кафедра
subjectId Дисциплина
lessonTypeId Тип занятия
timeSlotId Временной слот
parity BOTH, ODD, EVEN

Если указан только teacherId без groupId и departmentId, базовое расписание преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель назначен через newTeacherId: для каждой уникальной даты такой замены один раз строится базовый день, из него добавляются только указанные baseRuleSlotId, после чего применяются все overrides и выполняется окончательный фильтр преподавателя. Поэтому новый преподаватель видит назначенную замену, а исходный больше её не видит. Если релевантных замен нет, обход всех групп не выполняется.

Широкий пользовательский поиск без groupId, departmentId и teacher-only режима разрешён только до 50 активных групп; при большем количестве групп API вернёт 400 с просьбой уточнить группу или кафедру.

Ограничение относится только к интерактивному /api/schedule/search. Endpoints /api/workload/* используют отдельный агрегирующий путь и обрабатывают все активные группы разрешённого кафедрального scope, в том числе при количестве больше 50.

Пример:

GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27

Ответ совпадает со структурой RenderedLessonDto из GET /api/schedule. Для занятия, к которому применено разовое изменение, дополнительно заполнены:

  • scheduleOverrideId — идентификатор изменения;
  • overrideActionMOVE или REPLACE (CANCEL в выдачу не попадает);
  • originalLessonDate — исходная дата занятия из базового правила.

Перенос удаляет занятие из исходного дня и добавляет его в целевой. Поиск учитывает изменение, если в запрошенный диапазон попала исходная или целевая дата, поэтому входящий перенос находится даже запросом только по целевому диапазону. Для целевой даты пересчитываются день недели, номер недели и чётность; учёт академических часов остаётся привязан к исходному занятию.

Точечные изменения расписания учебного отдела

Метод URL Назначение
GET /api/edu-office/schedule/overrides?startDate=&endDate= Список изменений; диапазон проверяется по исходной или целевой дате
GET /api/edu-office/schedule/overrides/availability?baseRuleSlotId=&lessonDate= Границы семестра и допустимые учебные даты для конкретного занятия
POST /api/edu-office/schedule/overrides Создать перенос, отмену или замену
PUT /api/edu-office/schedule/overrides/{id} Обновить изменение
DELETE /api/edu-office/schedule/overrides/{id} Удалить изменение и вернуть актуальный вариант из правила

startDate и endDate у списка передаются только парой, включительно; максимальный диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя, аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.

{
  "baseRuleSlotId": 31,
  "lessonDate": "2026-05-21",
  "targetLessonDate": "2026-05-27",
  "action": "MOVE",
  "newTimeSlotId": 4,
  "newClassroomId": 2,
  "newTeacherId": 5,
  "newLessonFormat": "Онлайн",
  "comment": "Перенос конкретного занятия"
}

lessonDate всегда обозначает исходное занятие из правила. targetLessonDate передаётся только при переносе на другой день и не заменяет идентификатор исходной пары baseRuleSlotId + lessonDate.

Правила payload:

  • CANCEL отменяет конкретную пару; targetLessonDate и все поля new* должны отсутствовать;
  • MOVE требует новый временной слот; при переносе даты слот выбирается из эффективной сетки целевого дня, а преподавателя, аудиторию и формат можно изменить тем же запросом;
  • REPLACE используется только без изменения даты и времени и требует нового преподавателя, аудиторию или формат;
  • формат принимает только Очно или Онлайн;
  • MOVE и REPLACE должны фактически менять основные параметры действия. Другой ID временного слота с тем же интервалом не считается переносом.

До сохранения backend строит базовую пару на lessonDate по тем же правилам, что и обычное расписание: семестр, календарный график, чётность, активность сущностей и остаток часов. При переносе даты backend дополнительно проверяет тот же семестр, действие правила и дисциплины, lifecycle итоговых ресурсов, учебный календарь всех затронутых групп и принадлежность времени эффективной сетке целевого дня. Если пара не формируется или нарушена матрица действия, API возвращает 400 с русским сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией, группой или той же подгруппой, API возвращает 409 Conflict; соседние интервалы и разные подгруппы одной группы не конфликтуют.

Пример ответа availability:

{
  "semesterId": 3,
  "semesterStartDate": "2026-02-09",
  "semesterEndDate": "2026-06-30",
  "availableDates": ["2026-05-21", "2026-05-22", "2026-05-25"]
}

Загруженность

Метод URL Назначение
GET /api/workload/teachers Загруженность преподавателей
GET /api/workload/classrooms Загруженность аудиторий
GET /api/workload/departments Загруженность кафедр
GET /api/workload/time-slots Загруженность по парам
GET /api/workload/free-classrooms Свободные аудитории на дату и пару

Общие параметры для отчётов: startDate, endDate, опционально departmentId.

Роли ADMIN, EDUCATION_OFFICE и SCHEDULE_VIEWER могут не передавать departmentId для глобального отчёта или выбрать конкретную кафедру. Для роли DEPARTMENT backend всегда использует кафедру из access JWT: отсутствие параметра означает свою кафедру, а попытка передать чужой departmentId возвращает 403 Forbidden. То же ограничение применяется к GET /api/workload/free-classrooms, хотя этот endpoint не принимает departmentId.

Кафедра каждой записи определяется по teacher_department_assignments на дату занятия: сначала используется основное, затем дополнительное назначение. Поэтому период, включающий дату перевода, разделяет нагрузку одного преподавателя между прежней и новой кафедрами, а историческая нагрузка не зависит от текущего значения users.department_id.

Workload и поиск свободных аудиторий строятся через специализированный агрегирующий метод ScheduleQueryService: он сохраняет проверку диапазона, применение overrides, фильтр пары и дедупликацию, но не наследует UI-лимит 50 групп из /api/schedule/search.

Пример:

GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-01

Ответ:

[
  {
    "id": 2,
    "name": "Петров Препод Петрович",
    "departmentId": 1,
    "departmentName": "Кафедра ИБ",
    "lessonCount": 8,
    "academicHours": 16,
    "occupiedSlotCount": 8
  }
]

Кабинет кафедры

Метод URL Назначение
GET /api/department/subjects Дисциплины текущей кафедры
POST /api/department/subjects/import Загрузка списка дисциплин
GET /api/department/subjects/{subjectId}/comments Комментарии дисциплины
POST /api/department/subjects/{subjectId}/comments Добавить комментарий
GET /api/department/teachers Преподаватели кафедры
POST /api/department/teachers/{teacherId}/assignments Добавить существующего преподавателя на кафедру
GET /api/department/teacher-requests Заявки кафедры на создание преподавателей
POST /api/department/teacher-requests Создать заявку на нового преподавателя
GET /api/department/schedule Расписание кафедры

POST /api/department/subjects/import нормализует пробелы по краям и сравнивает названия без учёта регистра. Повторный импорт дисциплины своей кафедры обновляет код и восстанавливает архивную запись, не создавая новый ID. Если такое название уже принадлежит другой кафедре, API возвращает 409 Conflict; владелец записи не изменяется. Повторы одного названия внутри payload обрабатываются один раз, используется последнее значение.

GET /api/department/teachers возвращает актуальных преподавателей кафедры только по teacher_department_assignments. Основные и дополнительные назначения учитываются на текущую дату; архивные и ещё не начавшиеся назначения исключаются.

POST /api/department/teachers/{teacherId}/assignments создаёт дополнительную открытую связь преподавателя с кафедрой (is_primary=false). Для роли DEPARTMENT кафедра берётся из текущего пользователя, администратор может передать departmentId.

{
  "departmentId": 2,
  "comment": "Совместительство"
}

POST /api/department/teacher-requests принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.

{
  "username": "teacher.new",
  "fullName": "Новый Преподаватель",
  "jobTitle": "Доцент",
  "comment": "Нужен для дисциплин кафедры"
}

Кафедры и специальности

Кафедры

Метод URL Назначение
GET /api/departments Список кафедр
POST /api/departments Создать кафедру
PUT /api/departments/{id} Обновить кафедру
DELETE /api/departments/{id} Удалить кафедру

Тело создания/обновления:

{
  "departmentName": "Кафедра ИБ",
  "departmentCode": 1
}

Специальности

Метод URL Назначение
GET /api/specialties Список специальностей
POST /api/specialties Создать специальность
PUT /api/specialties/{id} Обновить специальность
DELETE /api/specialties/{id} Удалить специальность
GET /api/specialties/{id}/profiles Профили выбранной специальности
POST /api/specialties/{id}/profiles Создать профиль
PUT /api/specialties/{id}/profiles/{profileId} Обновить профиль
DELETE /api/specialties/{id}/profiles/{profileId} Удалить профиль

Тело создания/обновления:

{
  "specialityName": "Программная инженерия",
  "specialityCode": "09.03.04"
}

При создании специальности автоматически создаётся профиль Без профиля.

EDUCATION_OFFICE имеет read-only доступ к GET /api/specialties, GET /api/specialties/profiles и GET /api/specialties/{id}/profiles, необходимый для инициализации календарных учебных графиков. Создание, изменение, архивирование специальностей и CRUD профилей остаются доступны только ADMIN.

Тело создания/обновления профиля:

{
  "name": "Безопасность автоматизированных систем",
  "description": "Необязательное описание"
}

Группы

GET /api/groups

Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.

Параметр includeArchived=true возвращает все группы, включая архивные и завершившие обучение.

Ответ:

[
  {
    "id": 1,
    "name": "ИВТ-21-1",
    "groupSize": 25,
    "educationFormId": 1,
    "educationFormName": "Бакалавриат",
    "departmentId": 1,
    "yearStartStudy": 2023,
    "course": 3,
    "semester": 6,
    "specialtyId": 1,
    "specialtyCode": "09.03.04",
    "specialtyName": "Программная инженерия",
    "specialtyProfileId": 2,
    "specialtyProfileName": "Без профиля",
    "specialityCode": 1,
    "status": "ACTIVE",
    "active": true,
    "studyState": "ACTIVE",
    "studyStateName": "Активна"
  }
]

GET /api/groups/{departmentId}

Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.

POST /api/groups

Создание группы.

{
  "name": "ИВТ-11",
  "groupSize": 12,
  "educationFormId": 1,
  "departmentId": 1,
  "yearStartStudy": 2026,
  "specialtyId": 1,
  "specialtyProfileId": 2
}

groupSize, yearStartStudy и все связанные идентификаторы должны быть положительными; specialtyId и specialtyProfileId обязательны. Поле specialityCode сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из /api/specialties. Текущий курс вычисляется из yearStartStudy, но не опускается ниже 0, если обучение ещё не началось.

Поле active показывает, можно ли выбирать группу в текущих рабочих сценариях. studyState принимает значения ACTIVE, NOT_STARTED, GRADUATED, INACTIVE, ARCHIVED.

Название группы не является уникальным полем: допускается несколько групп с одинаковым name.

PUT /api/groups/{id}

Редактирование группы.

{
  "name": "ИВТ-11",
  "groupSize": 24,
  "educationFormId": 1,
  "departmentId": 1,
  "yearStartStudy": 2026,
  "specialtyId": 1,
  "specialtyProfileId": 2
}

Если у группы уже есть календарные назначения, изменение года начала обучения, специальности, профиля или формы обучения повторно проверяется для каждого учебного года. Несовместимое изменение возвращает 409 Conflict; группа и её назначения остаются без изменений.

Если у группы есть активные подгруппы, groupSize нельзя уменьшить ниже суммы их studentCapacity. Такой запрос отклоняется без изменения группы. Параллельные изменения группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL.

DELETE /api/groups/{id}

Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.

POST /api/groups/{id}/restore

Восстановление архивной группы.

Подгруппы группы

Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают subgroupId и subgroupIds.

studentCapacity обязателен и должен быть больше нуля. Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке groups настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы. Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую Подгруппа 1. Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.

Метод URL Назначение
GET /api/subgroups Список всех подгрупп
GET /api/groups/{groupId}/subgroups Подгруппы конкретной группы
POST /api/groups/{groupId}/subgroups Создать подгруппу
PUT /api/groups/{groupId}/subgroups/{id} Обновить подгруппу
DELETE /api/groups/{groupId}/subgroups/{id} Удалить подгруппу, если она не используется в расписании

Тело создания/обновления:

{
  "name": "Подгруппа 1",
  "studentCapacity": 12
}

Ответ:

{
  "id": 1,
  "groupId": 1,
  "groupName": "ИВТ-21-1",
  "name": "Подгруппа 1",
  "studentCapacity": 12
}

Календарные графики группы

Метод URL Назначение
GET /api/groups/{id}/calendar-assignments Список назначений графиков группе
PUT /api/groups/{id}/calendar-assignments Создать или заменить назначение на учебный год
DELETE /api/groups/{id}/calendar-assignments/{assignmentId} Удалить назначение

Тело назначения графика:

{
  "academicYearId": 1,
  "calendarId": 5
}

Ответ назначения:

{
  "id": 12,
  "groupId": 1,
  "groupName": "ИВТ-21-1",
  "academicYearId": 1,
  "academicYearTitle": "2025-2026",
  "calendarId": 5,
  "calendarTitle": "09.03.04 очная форма 2025-2026",
  "subjects": [
    {
      "id": 44,
      "calendarId": 5,
      "semesterNumber": 1,
      "subjectId": 1,
      "subjectName": "Высшая математика",
      "subjectCode": "Б1.О.01",
      "departmentId": 1
    }
  ]
}

Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. Вычисленный курс группы должен находиться в диапазоне 1..courseCount. Сохранение выполняется транзакционно с блокировкой группы, графика, учебного года и существующего назначения; конкурентный конфликт возвращает 409 Conflict.


Аудитории

GET /api/classrooms

Список аудиторий с привязанным оборудованием.

Ответ:

[
  {
    "id": 1,
    "name": "101 Ленинская",
    "capacity": 120,
    "building": "Главный корпус",
    "floor": 2,
    "isAvailable": true,
    "equipments": [
      { "id": 1, "name": "Проектор" },
      { "id": 4, "name": "Интерактивная доска" }
    ]
  }
]

POST /api/classrooms

Создание аудитории.

{
  "name": "404 Лаборатория",
  "capacity": 30,
  "building": "Лабораторный корпус",
  "floor": 4,
  "isAvailable": true,
  "equipmentIds": [1, 2, 3]
}

PUT /api/classrooms/{id}

Обновление аудитории (partial update).

DELETE /api/classrooms/{id}

Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях.

POST /api/classrooms/{id}/restore

Восстановление архивной аудитории.


Дисциплины

GET /api/subjects

Список всех дисциплин.

{ 
  "name": "Физика",
  "code": null,
  "departmentId": 1
}

GET /api/subjects/{departmentId}

Список всех дисциплин привязанных к кафедре.

POST /api/subjects

{ 
  "name": "Физика",
  "code": null,
  "departmentId": 1
}

DELETE /api/subjects/{id}

Удаление дисциплины.


Оборудование

GET /api/equipments

Список всего оборудования.

POST /api/equipments

{ "name": "3D-принтер" }

DELETE /api/equipments/{id}

Удаление оборудования.


Формы обучения

GET /api/education-forms доступен ADMIN, EDUCATION_OFFICE, DEPARTMENT и SCHEDULE_VIEWER. Создание POST /api/education-forms и удаление DELETE /api/education-forms/{id} разрешены ADMIN и EDUCATION_OFFICE; удаление по-прежнему отклоняется, если форма используется группой или календарным графиком.

GET /api/education-forms

Список форм обучения.

Ответ:

[
  { "id": 1, "name": "Бакалавриат" },
  { "id": 2, "name": "Магистратура" }
]

POST /api/education-forms

{ "name": "Аспирантура" }

DELETE /api/education-forms/{id}

Удаление формы обучения. Невозможно, если к ней привязаны группы или календарные учебные графики.


Привязка «Преподаватель ↔ Дисциплина»

GET /api/teacher-subjects

Список всех привязок.

Ответ:

[
  {
    "userId": 2,
    "userName": "Тестовый преподаватель",
    "subjectId": 1,
    "subjectName": "Высшая математика"
  }
]

POST /api/teacher-subjects

{
  "userId": 2,
  "subjectId": 3
}

Для роли DEPARTMENT дисциплина должна принадлежать кафедре текущего пользователя, а у преподавателя на текущую дату должна действовать основная или дополнительная связь с этой кафедрой.

DELETE /api/teacher-subjects

{
  "userId": 2,
  "subjectId": 3
}

Управление тенантами (Базы данных)

Все endpoints раздела доступны только пользователю с ролью ADMIN.

GET /api/database/status

Статус текущего подключения (определяется по домену запроса).

Ответ:

{
  "tenant": "default",
  "connected": true,
  "configured": true,
  "name": "Default",
  "url": "jdbc:postgresql://db:5432/app_db"
}

GET /api/database/tenants

Список всех тенантов. Пароль в ответ не включается.

[
  {
    "name": "СВФУ",
    "domain": "swsu",
    "url": "jdbc:postgresql://db-host:5432/swsu_db",
    "username": "dbuser",
    "connected": true
  }
]

POST /api/database/tenants

Создание нового или обновление существующего тенанта по domain.

{
  "name": "СВФУ",
  "domain": "swsu",
  "url": "jdbc:postgresql://db-host:5432/swsu_db",
  "username": "dbuser",
  "password": "dbpass"
}

domain приводится к нижнему регистру и должен быть одной DNS-меткой длиной от 1 до 63 символов. url обязателен и должен начинаться с jdbc:. Если name пуст, вместо него используется нормализованный domain.

Логика:

  1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает 503.
  2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
  3. Открывает соединение и явно проверяет его готовность.
  4. Выполняет Flyway-валидацию и миграции tenant-БД.
  5. Читает актуальный tenants-secret, применяет только upsert запрошенного domain и выполняет условный PUT с прочитанным Kubernetes resourceVersion.
  6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным retry/backoff; неизменившаяся конфигурация не записывается повторно.
  7. Одной атомарной публикацией заменяет связку TenantConfig + DataSource.
  8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов либо по истечении защитного таймаута.
  9. Возвращает внутри backend TenantLifecycleMutationResult с подтверждённой TenantSecretUpdateReceipt для согласования mounted-проекции.

Backend соединяется с Kubernetes API только через проверенный service-account CA и hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог Kubernetes updater.

При ошибке credentials, проверки соединения, Flyway или сохранения Secret временный pool закрывается, а прежнее подключение продолжает обслуживать запросы. Если Kubernetes подтвердил запись, но последующая локальная активация завершилась ошибкой, backend восстанавливает прежний снимок только пока Secret сохраняет resourceVersion этой записи. Более новое изменение другого pod не перезаписывается. Неопределённый сетевой результат сначала сверяется повторным чтением и не создаёт основания для небезопасной компенсации. Ошибка lifecycle возвращается как безопасный русский ответ без JDBC/Flyway details:

{
  "success": false,
  "message": "Не удалось выполнить миграции базы данных тенанта"
}

HTTP-статусы: 400 для некорректного payload и 503 для ошибки подключения, миграции, персистенции или активации.

Вне Kubernetes обновление Secret пропускается, поэтому добавленный через API tenant живёт только до перезапуска процесса. Для постоянной локальной конфигурации используется неотслеживаемый файл backend/tenants.json.

DELETE /api/database/tenants/{domain}

Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный domain и выполняет условный PUT по resourceVersion, затем атомарно исключает tenant из локальной маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления компенсация также допускается только для подтверждённой версии и не затирает более новое изменение другого pod. Неизвестный domain возвращает 404, lifecycle-ошибка — 503.

Для POST и DELETE baseline готовится до API-мутации под общим lifecycle monitor. После успеха backend использует семантические previousTenants и committedTenants из TenantSecretUpdateReceipt. Fence создаётся только для реального изменения общего Secret (persisted=true, changed=true): известные старые и промежуточные снимки временно откладываются, ожидаемый committed-снимок применяется полностью. Persisted no-op и локальная операция fence не создают, а неизвестный merged snapshot другого pod синхронизируется сразу. SHA-256 файла подтверждается только после полного успешного sync; ошибки повторяются с экспоненциальной задержкой от 30 до 300 секунд.

POST /api/database/test

Тест подключения к произвольной БД (без регистрации тенанта).

{
  "url": "jdbc:postgresql://host:5432/testdb",
  "username": "user",
  "password": "pass"
}

Ответ:

{
  "success": true,
  "message": "Подключение успешно!"
}

Коды ответов

Код Описание
200 Успех
400 Ошибка валидации или некорректные параметры запроса
401 Неверные учётные данные
403 Недостаточно прав для операции
404 Ресурс / тенант не найден
409 Конфликт бизнес-инвариантов или конкурентного изменения
500 Внутренняя ошибка сервера
503 Внешняя БД или обязательная инфраструктура временно недоступна