41 Commits

Author SHA1 Message Date
db0218f4ca Merge pull request 'Redesign' (#13) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 14s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 13s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m13s
Reviewed-on: #13
2026-06-25 16:43:54 +00:00
9e6e0038c6 Отображение вкладок в структуре вуза 2026-06-04 23:46:26 +03:00
6a03931190 исправлена 500 ошибка, пустое пространство в модалке 2026-06-04 10:50:12 +03:00
dipatrik10
3d50b6b006 Исправил косяки в модалках решения конфликтов правил 2026-06-03 21:22:24 +03:00
dipatrik10
53298d9d07 Добавил возможность решения конкфликтов при создании правил 2026-06-03 13:55:31 +03:00
776728cf83 Структура вуза, визуал 2026-06-03 12:16:37 +03:00
dipatrik10
08b8607f05 Добавил вкладку заявок 2026-06-02 16:07:30 +03:00
6d9f6b7a42 Изменен фильтр в "группы обучения", изменена механика создания подгрупп 2026-06-02 11:21:02 +03:00
4e1985c28e исправил чекбоксы оборудования в аудиторном фонде 2026-06-02 00:29:23 +03:00
dipatrik10
a966648a7e Сделал обработку на ВЕ конфликтных правил 2026-06-02 00:24:57 +03:00
dipatrik10
f3273f6972 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-06-02 00:02:06 +03:00
dipatrik10
4adce0b89c Добавил запрос на создание препода от кафедры 2026-06-02 00:01:14 +03:00
43d72c5235 Добавить дисциплины календарных графиков 2026-06-01 23:27:37 +03:00
Zuev
4b05d4287c поправил поля ввода и вызово боковой панели конструктора правил 2026-06-01 21:14:30 +03:00
Zuev
5a81a911ff devops 2026-06-01 20:50:18 +03:00
57e55298d5 удалены лишние файлы 2026-06-01 17:00:21 +03:00
da7c90fa6a Merge pull request 'Redesign' (#12) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 50s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 14s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m49s
Reviewed-on: #12
2026-06-01 09:04:35 +00:00
3217586657 редизайн кнопок 1st version 2026-06-01 02:55:06 +03:00
dipatrik10
30972db9ee Исправил часть косяков в конструкторе правил 2026-05-31 21:36:23 +03:00
Zuev
f3a9905423 профили 2026-05-31 16:34:05 +03:00
Zuev
7926038bdc обновил дизайн, добавил дашборд 2026-05-31 16:18:27 +03:00
Zuev
e2a3553a09 поправил добавление дисциплин и расписание занятий 2026-05-31 14:42:43 +03:00
e989391c8d Поправил сетку и еще пару багов 2026-05-28 00:52:21 +03:00
5b6ffa5c08 поправил вид сетки и прочие баги 2026-05-28 00:47:51 +03:00
dipatrik10
bbfee068da Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule 2026-05-27 21:26:22 +03:00
dipatrik10
9df67400b8 test 2026-05-27 21:25:33 +03:00
dipatrik10
c19a9b34c7 Добавил визуальную матрицу расписания групп 2026-05-27 21:18:57 +03:00
Zuev
37850b8e06 исправил логику курса групп 2026-05-27 21:04:54 +03:00
Zuev
e9575cf2ce исправил ошибки и проблемы безопасности 2026-05-27 20:29:50 +03:00
Zuev
c7cb5125f4 Merge branch 'origin/dynamic_schedule' into dynamic_schedule 2026-05-27 17:23:27 +03:00
Zuev
d4ace6aab2 переделал токен
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 13s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m48s
2026-05-27 16:48:33 +03:00
Zuev
87cd4cbdc7 переделал токен 2026-05-27 15:22:23 +03:00
52328ae6c0 Merge pull request 'Всё в прод' (#11) from dynamic_schedule into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 4m49s
Reviewed-on: #11
2026-05-27 10:27:09 +00:00
d2ef44471b Merge branch 'main' into dynamic_schedule 2026-05-27 10:26:46 +00:00
dipatrik10
467f78c3d0 Работа с сеткой в графике загруженности 2026-05-27 13:22:59 +03:00
dipatrik10
eb023c07fb Merge remote-tracking branch 'origin/main'
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m4s
# Conflicts:
#	AGENTS.md
#	docs/API.md
#	docs/FRONTEND.md
#	frontend/admin/js/main.js
2026-04-09 21:33:06 +03:00
dipatrik10
d2b9d0c5ea Merge branch 'department_dev' 2026-04-09 21:22:44 +03:00
Zuev
e03a68b7a8 feat: add frontend-design skill with its documentation and license, and update gitignore.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m55s
2026-03-23 02:05:02 +03:00
Zuev
fcd7baac71 feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 10s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m54s
2026-03-22 15:22:10 +03:00
Zuev
491807cd94 docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 17s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m57s
2026-03-22 02:49:13 +03:00
Zuev
81e91e056f feat: Add OpenTelemetry integration by creating otel.js and importing it into main.js.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m57s
2026-03-19 23:47:01 +03:00
137 changed files with 12718 additions and 2822 deletions

View File

@@ -0,0 +1,85 @@
---
name: AutoUpdateDocs
description: Автоматическое обновление документации проекта после изменений в коде
---
# Скилл: Автоматическое обновление документации
## Когда активировать
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
- **Общая структура проекта** → обновить `docs/README.md`
## Карта соответствия «файл → документация»
| Изменённый файл/директория | Файл документации |
|----------------------------|-------------------|
| `controller/*Controller.java` | `docs/API.md` |
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
| `model/*.java` | `docs/DATABASE.md` |
| `dto/*.java` | `docs/API.md` |
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` |
## Пошаговая инструкция
### 1. Определить затронутые файлы документации
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
### 2. Прочитать текущую документацию
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
### 3. Внести точечные изменения
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
#### Новый контроллер → `docs/API.md`
Добавить новую секцию с описанием эндпоинтов:
- Метод + URL
- Тело запроса (JSON пример)
- Ответ (JSON пример)
- Валидация
#### Новая миграция → `docs/DATABASE.md`
- Добавить новую таблицу в ER-диаграмму (Mermaid)
- Добавить описание таблицы и колонок
- Добавить запись в таблицу «Текущие миграции»
#### Новый view → `docs/FRONTEND.md`
- Добавить в дерево файлов
- Добавить в таблицу «Разделы админ-панели»
### 4. Обновить AGENTS.md (при необходимости)
Если изменения затрагивают:
- Структуру директорий → обновить дерево в `AGENTS.md`
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
### 5. Сообщить пользователю
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
## Правила
1. **Язык:** Вся документация на русском языке
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения

View File

@@ -3,7 +3,7 @@
web_search = "live" web_search = "live"
developer_instructions = """ developer_instructions = """
Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
Используй проектные скиллы из .agents/skills, когда задача соответствует их description. Используй проектные скиллы из .agents/skills, когда задача соответствует их description.

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
db/data/ db/data/
.env .env
backend/tenants.json
# Игнорируем временные файлы сборки (на будущее) # Игнорируем временные файлы сборки (на будущее)
backend/target/ backend/target/
@@ -10,4 +11,4 @@ frontend/dist/
.idea/ .idea/
.vscode/ .vscode/
*.DS_Store *.DS_Store
skills-lock.json skills-lock.json

View File

@@ -62,7 +62,7 @@ docker compose logs -f backend
## Критические правила для агентов ## Критические правила для агентов
### Flyway миграции ### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. - **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` - Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) - Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)

278
DEVOPS.md Normal file
View File

@@ -0,0 +1,278 @@
# РАЗРАБОТКА И ВНЕДРЕНИЕ ОТКАЗОУСТОЙЧИВОЙ МУЛЬТИТЕНАНТНОЙ ИНФРАСТРУКТУРЫ ДЛЯ СИСТЕМЫ УПРАВЛЕНИЯ УНИВЕРСИТЕТСКИМ РАСПИСАНИЕМ «МАГИСТР»
**Диссертационное исследование и отчет о проделанной инженерной работе в качестве DevOps-архитектора проекта**
---
## ВВЕДЕНИЕ
Современные информационные системы для образовательных учреждений требуют строгого соблюдения требований к безопасности, изоляции данных различных организаций (университетов), гибкости масштабирования и высокой наблюдаемости (observability). В рамках разработки проекта **«Магистр»** — системы управления расписанием учебных занятий — передо мной встала задача проектирования и построения инфраструктуры, способной обслуживать десятки университетов в рамках единого прикладного контура при условии жесткой изоляции их баз данных.
В качестве DevOps-инженера проекта я спроектировал и реализовал архитектурное решение, объединяющее технологии аппаратной виртуализации, оркестрации контейнеров, автоматизации конфигурации, распределенного мониторинга и непрерывной интеграции. В данном документе подробно описаны теоретические предпосылки, практическая реализация и архитектурные решения, внедренные мной в продакшн-окружение проекта.
---
## РАЗДЕЛ 1. СИСТЕМНАЯ АРХИТЕКТУРА И КОНЦЕПЦИЯ МУЛЬТИТЕНАНТНОСТИ
### 1.1 Выбор паттерна изоляции данных
При проектировании мультитенантных (multi-tenant) систем классически выделяют три подхода к организации баз данных:
1. **Shared Database & Shared Schema**: Все клиенты используют общие таблицы, записи разделяются по полю `tenant_id`. Подход дешев в обслуживании, но несет колоссальные риски утечки данных из-за ошибок в SQL-запросах приложения и не позволяет разграничивать физический доступ к базам.
2. **Shared Database & Separate Schemas**: Одна СУБД, но разные логические схемы для каждого клиента. Обеспечивает умеренную изоляцию, но сохраняет единую точку отказа и общие аппаратные ресурсы СУБД.
3. **Database-per-Tenant (Выбранный подход)**: Каждый клиент (университет) владеет собственной физически изолированной базой данных, расположенной на выделенном сервере или виртуальной машине.
Для проекта «Магистр» мной был выбран и реализован паттерн **Database-per-Tenant**. Это обусловлено спецификой клиентов (высшие учебные заведения), которые согласно законодательству (например, ФЗ-152 «О персональных данных») обязаны хранить свои данные локально или требовать полной изоляции конфиденциальной информации. Кроме того, данный подход позволяет:
* Исключить влияние высокой нагрузки одного университета (например, в период составления сессий) на доступность системы для других вузов («синдром шумного соседа»).
* Выполнять индивидуальное резервное копирование и обслуживание БД каждого университета без остановки всей платформы.
* Переносить базы данных вузов на их собственные физические серверы в локальных сетях (on-premise), сохраняя при этом работу приложения в центральном облаке.
### 1.2 Архитектурная схема движения трафика
Ниже представлена разработанная мной схема прохождения сетевых запросов и агрегации телеметрии в системе:
```mermaid
graph TD
Client[Пользовательский браузер] -->|HTTPS: tenant.zuev.company| Caddy[Caddy Reverse Proxy]
Caddy -->|HTTP Round-Robin| Ingress[Traefik Ingress Controller]
subgraph K3s Cluster
Ingress -->|Route /*| Frontend[Frontend Pods x2 Apache]
Ingress -->|Route /api/*| Backend[Backend Pods x1-x2 Spring Boot]
Backend -->|K8s API: Get/Patch CM| K8sAPI[Kubernetes API Server]
BackendConfig[ConfigMap: tenants-config] -.->|Mounted JSON| Backend
end
subgraph Proxmox VE Infrastructure
Backend -->|JDBC Connection| VM1[(VM 1: DBMS MSU<br>PostgreSQL 16)]
Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU<br>PostgreSQL 16)]
VM1 -.->|Metrics OTLP/gRPC| Collector1[OTel Collector VM1]
VM2 -.->|Metrics OTLP/gRPC| Collector2[OTel Collector VM2]
end
subgraph Observability Hub
Backend -->|Logs & Traces OTLP| SigNoz[SigNoz APM Server]
Collector1 -->|Postgres Metrics| SigNoz
Collector2 -->|Postgres Metrics| SigNoz
end
classDef cluster fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef vm fill:#efebe9,stroke:#4e342e,stroke-width:2px;
classDef monitor fill:#efe8ff,stroke:#512da8,stroke-width:2px;
class K3s Cluster cluster;
class Proxmox VE Infrastructure vm;
class Observability Hub monitor;
```
---
## РАЗДЕЛ 2. АППАРАТНАЯ ВИРТУАЛИЗАЦИЯ НА БАЗЕ PROMVOX VE
Для размещения баз данных клиентов и вспомогательных инфраструктурных служб мной был развернут гипервизор **Proxmox Virtual Environment (PVE)** на выделенных серверных мощностях.
### 2.1 Конфигурация виртуализации и изоляции ресурсов
Каждая база данных университета разворачивается в выделенной виртуальной машине (KVM), что гарантирует жесткую изоляцию на уровне ядра ОС и гипервизора.
* **Сетевой уровень**: В Proxmox настроена виртуальная коммутация (Linux Bridge `vmbr0`). Сетевые интерфейсы виртуальных машин баз данных вынесены в приватную подсеть, изолированную от внешней сети. Доступ к ним имеет только подсеть кластера Kubernetes и управляющая машина администратора.
* **Дисковая подсистема**: Использовано локальное хранилище на базе ZFS с зеркалированием (RAID-10 на SSD-накопителях). Это обеспечивает:
* Высокую скорость операций ввода-вывода (IOPS), критически важную для СУБД при транзакционных нагрузках.
* Механизм мгновенных снимков (snapshots) для безопасного проведения обновлений.
* Аппаратное сжатие данных (LZ4) без потери производительности, снижающее износ накопителей.
* **Резервное копирование**: Интегрирован **Proxmox Backup Server (PBS)**. Каждую ночь выполняются инкрементальные бэкапы виртуальных машин с дедупликацией на уровне блоков данных, что минимизирует нагрузку на сеть и диски, позволяя восстановить любую БД на любой момент времени за последние 30 дней.
---
## РАЗДЕЛ 3. ОРКЕСТРАЦИЯ КОНТЕЙНЕРНОЙ ИНФРАСТРУКТУРЫ В KUBERNETES (K3s)
В качестве платформы оркестрации приложений был выбран **K3s** — сертифицированный дистрибутив Kubernetes от Rancher, оптимизированный под средние и малые нагрузки, обладающий минимальными накладными расходами на RAM/CPU для Control Plane, но сохраняющий полную совместимость с upstream-спецификациями Kubernetes API.
### 3.1 Архитектура манифестов и структура ресурсов
Мной была разработана декларативная структура ресурсов, развернутая в изолированном пространстве имен `magistr` (манифест `namespace.yaml`):
* **Frontend (`frontend.yaml`)**:
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`).
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
* **Backend (`backend.yaml`)**:
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
* Для сборки образов используется multi-stage сборка Maven (JDK 17) и запуск под управлением `eclipse-temurin:17-jre-alpine`.
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
* **Ingress (`ingress.yaml`)**:
* В роли Ingress-контроллера выступает стандартный для K3s **Traefik**.
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` для тестового окружения, `n8n.zuev.company` и т.д.). Маршрутизация по путям разделяет трафик: запросы к API (`/api`) проксируются на бэкенд-сервис, а запросы к статическим ресурсам и страницам (`/`) — на фронтенд-сервис.
### 3.2 Реализация динамической мультитенантности через K8s API
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
1. **Хранение конфигурации**:
Список подключений к базам данных университетов хранится в JSON-формате (`tenants.json`) внутри Kubernetes `ConfigMap` с именем `tenants-config`.
2. **Проблема Read-Only монтирования**:
По умолчанию Kubernetes монтирует ConfigMap как файловую систему в режиме Read-Only. Это исключало возможность для Java-приложения перезаписывать `tenants.json` при запросах от администратора на добавление нового тенанта.
3. **Решение с использованием `initContainer` и `emptyDir`**:
Я спроектировал схему с временным перезаписываемым томом (`emptyDir`):
* В спецификацию пода backend добавлен `initContainer` на базе образа `busybox`.
* При старте пода `initContainer` монтирует ConfigMap `tenants-config` и копирует файл `tenants.json` во временный раздел `emptyDir` (путь `/config`).
* Основной контейнер `backend` монтирует этот же `emptyDir` в режиме Read-Write.
4. **K8s RBAC и динамическое сохранение**:
Чтобы предоставить бэкенду возможность сохранять изменения конфигурации обратно в кластер, мной были написаны манифесты авторизации (`rbac.yaml`):
* Создан `ServiceAccount` `backend-sa` и назначен поду бэкенда.
* Описана Kubernetes `Role` `backend-configmap-role`, дающая права только на операции `get` и `patch` для ресурса `configmaps` с конкретным именем `tenants-config` (принцип наименьших привилегий).
* Создан `RoleBinding` `backend-configmap-binding`, связывающий сервис-аккаунт с этой ролью.
В коде бэкенда класс `ConfigMapUpdater` через Kubernetes Java Client отправляет PATCH-запрос к API-серверу при добавлении нового тенанта, обновляя ConfigMap в самом кластере.
5. **Репликация и синхронизация подов**:
Для обеспечения консистентности данных в условиях работы нескольких реплик бэкенда:
* Класс `TenantConfigWatcher` запускает фоновую задачу, которая каждые 30 секунд сверяет MD5-хеш локального файла `tenants.json` с версией из ConfigMap.
* При обнаружении расхождения файл перезаписывается, DataSource-инстансы пересоздаются в памяти приложения на лету, исключая рассинхронизацию подов.
* *Примечание*: В ходе тестирования для минимизации задержек и исключения эффекта "мигания" данных на время отладки количество реплик backend было ограничено до 1, с последующим переходом на полноценный StatefulSet/Shared Storage при росте количества нод.
### 3.3 Повышение отказоустойчивости бэкенда (Resiliency)
При запуске бэкенда в облаке возникала проблема: если база данных одного из университетов становилась недоступной (например, из-за сетевого сбоя или регламентных работ), пул подключений HikariCP падал с ошибкой при инициализации бинов, отправляя под бэкенда в циклическую перезагрузку (`CrashLoopBackOff`), что делало недоступной систему для *всех* остальных вузов.
Для предотвращения этого каскадного сбоя я внедрил комплекс защитных механизмов:
* **H2 Fallback**: В сборку (`pom.xml`) добавлена СУБД H2 in-memory. Если при старте ConfigMap пуст, Spring Boot инициализирует пустой JPA-контекст на базе H2, что позволяет приложению успешно пройти фазу запуска.
* **HikariCP Resiliency**: В конфигурации источников данных задано свойство `setInitializationFailTimeout(-1)`. Это заставляет HikariCP игнорировать отсутствие связи с целевой БД при старте приложения, продолжая попытки подключения в фоновом режиме.
* **Явное указание диалекта Hibernate**: Принудительно задан `org.hibernate.dialect.PostgreSQLDialect` в `TenantDataSourceConfig.java`. Это избавило Hibernate от необходимости выполнять тестовый запрос к БД для автоматического определения диалекта на ранних этапах инициализации контекста.
* **Автоматическое создание таблиц (`DataInitializer`)**: Для новых БД вузов полностью автоматизирован процесс наката структуры. При обнаружении новой базы Java-код проверяет наличие таблицы `users` и, в случае ее отсутствия, самостоятельно применяет SQL-скрипт инициализации (`init.sql`), включая создание дефолтных справочников и учетной записи суперадминистратора.
---
## РАЗДЕЛ 4. АВТОМАТИЗАЦИЯ КОНФИГУРАЦИИ СУБД С ПОМОЩЬЮ ANSIBLE
Для развертывания и администрирования баз данных университетов на удаленных виртуальных машинах Proxmox мной был спроектирован и реализован комплексный плейбук **Ansible** с модульной структурой ролей.
### 4.1 Структура и функционал Ansible-ролей
Вся конфигурация целевого сервера разбита на 6 последовательных этапов (плейбук `site.yml`):
1. **`common` (Базовая подготовка и Hardening ОС)**:
* Создание системного пользователя `deploy` с беспарольным доступом к `sudo` (через валидацию файла `/etc/sudoers.d/deploy` утилитой `visudo`).
* Установка системных утилит (`curl`, `git`, `htop`, `chrony` для синхронизации времени по NTP).
* **SSH Hardening**: Изменение стандартного SSH-порта на `2222`, отключение авторизации по паролям, запрет входа для пользователя `root`, ограничение таймаута сессий.
* Адаптация под различные дистрибутивы ОС: написаны отдельные сценарии для семейств Debian/Ubuntu/Astra Linux (пакетный менеджер `apt`), RedHat/РЕД ОС (`dnf`) и ALT Linux (кастомная обертка для `apt-get`).
2. **`docker` (Среда контейнеризации)**:
* Автоматическое добавление официальных GPG-ключей и репозиториев Docker CE.
* Установка Docker Engine, CLI и Compose плагина.
* Включение системной службы `docker` и добавление пользователя `deploy` в группу `docker` для беспарольного запуска контейнеров.
3. **`firewall` (Сетевая безопасность)**:
* Настройка межсетевого экрана UFW (для Debian-based систем) или firewalld (для RedHat и ALT Linux).
* Полная блокировка входящего трафика по умолчанию.
* Разрешение входящих пакетов только на порт SSH (`2222`) и порт PostgreSQL (`5432`). Реализована поддержка ограничения доступа к порту `5432` только для IP-адресов нод Kubernetes-кластера (`firewall_postgresql_allowed_ips`).
4. **`postgresql` (Контейнеризированная СУБД)**:
* Генерация файлов конфигурации `docker-compose.yml` и `.env` на основе шаблонов Jinja2.
* Развертывание PostgreSQL 16 на базе легковесного Alpine-образа.
* Физическое монтирование каталога данных БД с хост-системы (`/opt/magistr/postgres/data`), предотвращающее потерю данных при пересоздании контейнера.
* Ограничение системных ресурсов контейнера (limits: memory: 512M) и тюнинг параметров PostgreSQL (`shared_buffers=256MB`, оптимизация пула соединений, логирование медленных запросов `log_min_duration_statement=1000`).
5. **`backup` (Резервное копирование на уровне БД)**:
* Установка на хост автоматического bash-скрипта бэкапа.
* Настройка задачи `cron` (запуск ежедневно в 03:00).
* Скрипт осуществляет горячий дамп базы через `docker exec pg_dump`, архивирует его с помощью `gzip` и сохраняет в локальный каталог `/opt/magistr/backups/`.
* Реализован алгоритм автоматической ротации: удаление архивных файлов старше заданного количества дней (по умолчанию 14 дней).
6. **`monitoring` (Сбор телеметрии хоста и БД)**:
* Развертывание контейнера OpenTelemetry Collector на целевом сервере БД.
* Конфигурирование коллектора на сбор метрик производительности PostgreSQL и ОС (процессор, память, диски) и их отправку на единый сервер SigNoz по OTLP/HTTP.
### 4.2 Управление секретами: Ansible Vault
Для исключения попадания паролей администраторов БД в систему контроля версий (Git) все чувствительные переменные (например, `vault_postgresql_password`) вынесены в файл `group_vars/databases/vault.yml` и зашифрованы алгоритмом AES-256 с помощью **Ansible Vault**.
Для бесшовной интеграции в CI/CD пароль расшифрования считывается из локального файла `.vault_pass` на управляющей машине, доступ к которому ограничен правами `chmod 600`.
---
## РАЗДЕЛ 5. НЕПРЕРЫВНАЯ ИНТЕГРАЦИЯ И ДОСТАВКА (CI/CD)
В рамках оптимизации внутренних процессов разработки и деплоя мной была развернута и настроена инфраструктура непрерывной интеграции на базе **Gitea** и **Gitea Actions**.
### 5.1 Автоматизация сборки (CI)
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
1. **Checkout**: Загрузка актуального исходного кода проекта на ранер.
2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев.
3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`).
4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр.
### 5.2 Доставка в кластер (CD)
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет типа `docker-registry` в пространстве имен `magistr`:
```bash
kubectl create secret docker-registry gitea-registry \
--docker-server=gitea.zuev.company \
--docker-username=Zuev \
--docker-password=${ZUEV_TOKEN} \
--namespace=magistr
```
Этот секрет ассоциирован со спецификациями деплоев в `backend.yaml` и `frontend.yaml` через директиву `imagePullSecrets`. Обновление приложений в кластере после завершения сборки образов выполняется путем контролируемого перезапуска подов:
```bash
kubectl rollout restart deployment backend -n magistr
kubectl rollout restart deployment frontend -n magistr
```
---
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
Для маршрутизации внешнего трафика и защиты соединений на входе в инфраструктуру развернут веб-сервер **Caddy**.
### 6.1 Преимущества Caddy и управление SSL/TLS
В отличие от классического Nginx, Caddy был выбран благодаря:
* Встроенной интеграции с удостоверяющими центрами Let's Encrypt и ZeroSSL. При добавлении нового поддомена вуза (например, `swsu.zuev.company`) Caddy автоматически запрашивает, валидирует через DNS/HTTP-вызовы и устанавливает TLS-сертификат, а также следит за его продлением.
* Лаконичному синтаксису конфигурации (`Caddyfile`).
* Полноценной поддержке протокола HTTP/3 «из коробки».
### 6.2 Конфигурация балансировки
Внешний Caddy-сервер принимает запросы ко всем поддоменам `*.zuev.company` и перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика нагрузки (L4/L7 Load Balancer):
```caddyfile
*.zuev.company {
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
}
}
```
Сетевые запросы распределяются по нодам K3s по алгоритму Round-Robin. В случае недоступности одной из нод, Caddy временно исключает ее из пула, обеспечивая отказоустойчивость инфраструктуры на сетевом уровне.
---
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** и стандартов **OpenTelemetry (OTel)**.
### 7.1 Сбор телеметрии Backend
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
* **Трейсы (Traces)**: Сквозное прохождение HTTP-запросов через контроллеры, сервисы и JDBC-драйвер к базам данных.
* **Метрики (Metrics)**: Метрики JVM (утилизация Heap, активность сборщика мусора GC, состояние потоков), метрики HTTP-запросов (интенсивность, латентность, коды ответов).
* **Логи (Logs)**: Системные логи Logback экспортируются по протоколу OTLP напрямую в SigNoz.
### 7.2 Идентификация тенантов в мониторинге
Для глубокого анализа производительности СУБД конкретных университетов критически важно разделять телеметрию по тенантам. Мной была реализована сквозная маркировка:
* В бэкенде через Java-интерцептор `TenantInterceptor` при каждом запросе идентификатор тенанта помещается в контекст логирования SLF4J MDC (`MDC.put("tenant.id", tenant)`) и одновременно записывается в атрибуты текущего OpenTelemetry спана (`Span.current().setAttribute("tenant.id", tenant)`).
* Благодаря переменной окружения `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=tenant.id`, OTel-агент автоматически парсит MDC и прикрепляет его к логам, отправляемым в SigNoz. Это позволяет администраторам фильтровать ошибки и строить графики нагрузки в разрезе каждого университета.
### 7.3 Мониторинг баз данных на хостах
Для сбора детальной статистики с изолированных серверов баз данных:
1. На хостах БД силами Ansible разворачивается OpenTelemetry Collector.
2. В конфигурации коллектора (`otel-collector-config.yml.j2`) описывается ресивер `postgresql`, который подключается к локальной БД и считывает системные метрики (размер таблиц, cache hit ratio, количество транзакций, активные сессии, блокировки).
3. К собираемым метрикам жестко прикрепляются ресурсные атрибуты хоста: `service.name=magistr-db-<tenant_domain>` и `university.name=<University Name>`.
4. Метрики экспортируются в центральный коллектор SigNoz. В результате в интерфейсе SigNoz доступны кастомные дашборды JVM, PostgreSQL и HTTP, позволяющие оперативно локализовать проблемы с производительностью баз данных конкретных вузов.
---
## ЗАКЛЮЧЕНИЕ
Разработанная и внедренная мной DevOps-архитектура для проекта «Магистр» успешно решила задачи изоляции данных, отказоустойчивости и автоматизации администрирования.
### Ключевые результаты проделанной работы:
1. **Изоляция данных**: Реализована физическая изоляция БД университетов на выделенных серверах (VM в Proxmox VE), соответствующая требованиям безопасности и законодательства.
2. **Динамическое масштабирование**: Внедрен механизм динамического добавления тенантов без простоя системы (zero-downtime) за счет связки Spring Boot, K8s RBAC и ConfigMapWatcher.
3. **Отказоустойчивость**: Минимизированы риски каскадных сбоев бэкенда за счет применения in-memory fallback баз данных и оптимизации параметров подключения HikariCP.
4. **Автоматизация**: Создан универсальный Ansible-плейбук для быстрого ввода в эксплуатацию новых серверов БД (время развертывания «под ключ» сокращено до нескольких минут).
5. **Наблюдаемость**: Реализован сквозной мониторинг логов, метрик и трассировок с детализацией по тенантам на базе стека OpenTelemetry + SigNoz.
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».

View File

@@ -1,248 +0,0 @@
# Отчёт об изменениях по задачам научного руководителя
Дата: 2026-05-19
## Краткий итог
Выполнен первый цельный релиз архитектурной основы:
- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`;
- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`;
- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`;
- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю;
- добавлена история переводов преподавателей между кафедрами;
- добавлены точечные изменения расписания: перенос, отмена, замена;
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
- обновлена документация проекта.
## Backend
### Роли и авторизация
Поддерживаются роли:
- `ADMIN`;
- `EDUCATION_OFFICE`;
- `DEPARTMENT`;
- `SCHEDULE_VIEWER`;
- `TEACHER`;
- `STUDENT`.
Добавлены:
- `AuthSessionService` — in-memory UUID-сессии;
- `AuthContext` — текущий пользователь в `ThreadLocal`;
- `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`;
- `@RequireRoles` — ограничение доступа на уровне контроллеров и методов;
- `GET /api/auth/me` — текущий пользователь по токену.
Redirect после входа:
- `ADMIN` -> `/admin/`;
- `EDUCATION_OFFICE` -> `/admin/#schedule-view`;
- `DEPARTMENT` -> `/admin/#department-workspace`;
- `SCHEDULE_VIEWER` -> `/admin/#schedule-view`;
- `TEACHER` -> `/teacher/`;
- `STUDENT` -> `/student/`.
### Миграция БД
Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта.
Все изменения схемы внесены в:
```text
backend/src/main/resources/db/migration/V1__init.sql
```
`V1__init.sql` теперь сразу создаёт:
- lifecycle-поля к справочникам и пользователям;
- `teacher_department_assignments`;
- `subject_comments`;
- version-поля к `schedule_rules`;
- lock-поля к `schedule_rule_slots`;
- `schedule_overrides`;
- стартовых пользователей `учебный_отдел`, афедраб` и `просмотр_расписаний`.
### Архивирование вместо удаления
Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания.
Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки.
### История кафедр преподавателя
Добавлены endpoint:
- `GET /api/users/{id}/department-history`;
- `POST /api/users/{id}/department-transfer`;
- `GET /api/users/teachers/by-department/{departmentId}?date=YYYY-MM-DD`.
При переводе преподавателя старая запись закрывается, новая открывается с `valid_from`, а `users.department_id` обновляется как текущая кафедра.
### Расписание и загруженность
Добавлены:
- `GET /api/schedule/search`;
- `GET /api/workload/teachers`;
- `GET /api/workload/classrooms`;
- `GET /api/workload/departments`;
- `GET /api/workload/time-slots`;
- `GET /api/workload/free-classrooms`;
- `GET/POST/PUT/DELETE /api/edu-office/schedule/overrides`.
`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания.
Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам.
### Кабинет кафедры API
Добавлены:
- `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`;
- `GET /api/department/schedule`.
Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI.
## Frontend
### Единая панель по ролям
Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель:
- `/department/` -> `/admin/#department-workspace`;
- `/edu-office/` -> `/admin/#schedule-view`.
В `frontend/admin/js/main.js` добавлена фильтрация вкладок:
- `ADMIN` видит все вкладки;
- `EDUCATION_OFFICE` видит просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории и оборудование;
- `DEPARTMENT` видит кабинет кафедры и просмотр расписаний;
- `SCHEDULE_VIEWER` видит только просмотр расписаний.
Backend не опирается только на скрытие вкладок. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, а `@RequireRoles` ограничивает операции на контроллерах и методах. Для кафедры дополнительно закрыты обходные пути:
- общий `/api/subjects` оставлен для записи только администратору, кафедра загружает дисциплины через scoped `/api/department/subjects/import`;
- `/api/teacher-subjects` для роли `DEPARTMENT` проверяет, что и преподаватель, и дисциплина относятся к кафедре текущего пользователя;
- `SCHEDULE_VIEWER` имеет только read-only доступ к расписаниям и справочникам-фильтрам.
После браузерной проверки исправлено визуальное скрытие недоступных вкладок: `layout.css` теперь принудительно скрывает `.nav-item[hidden]` и скрытые пункты меню настроек, поэтому роли больше не видят чужие вкладки в sidebar.
### Просмотр расписаний
Добавлена вкладка:
```text
frontend/admin/views/schedule-view.html
frontend/admin/js/views/schedule-view.js
```
Возможности:
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
### Загруженность
Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
Возможности:
- выбор типа загруженности: аудитории, преподаватели или кафедры;
- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
- кафедральная матрица группирует занятия по кафедре преподавателя;
- для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
### Кабинет кафедры
Добавлена вкладка:
```text
frontend/admin/views/department-workspace.html
frontend/admin/js/views/department-workspace.js
```
Возможности:
- просмотр дисциплин кафедры;
- загрузка дисциплин из списка `код; название`;
- просмотр и добавление комментариев к дисциплинам;
- просмотр преподавателей кафедры;
- просмотр нагрузки преподавателей кафедры за период.
### Админка
Обновлено:
- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`;
- удаление пользователей заменено на архивирование;
- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию;
- удаление аудитории заменено на вывод из эксплуатации;
- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`.
## Документация
Обновлены:
- `AGENTS.md`;
- `docs/README.md`;
- `docs/API.md`;
- `docs/DATABASE.md`;
- `docs/BUSINESS_LOGIC.md`;
- `docs/FRONTEND.md`;
- `docs/ARCHITECTURE.md`.
## Проверки
Статически проверены новые и изменённые JS-файлы:
```bash
node --check frontend/admin/js/main.js
node --check frontend/admin/js/views/schedule-view.js
node --check frontend/admin/js/views/auditorium-workload.js
node --check frontend/admin/js/views/department-workspace.js
node --check frontend/admin/settings/js/main.js
node --check frontend/admin/js/api.js
node --check frontend/admin/js/views/users.js
node --check frontend/admin/js/views/classrooms.js
git diff --check
```
Backend-компиляция проверяется через Docker Maven:
```bash
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
```
Результат: компиляция прошла успешно.
Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
## Что осталось следующим этапом
Остались задачи, которые требуют отдельного цикла проработки:
- UI для просмотра истории переводов преподавателя в админке;
- UI для списка и редактирования уже созданных `schedule_overrides`;
- более строгая проверка конфликтов перед сохранением переносов;
- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.

View File

@@ -50,6 +50,12 @@
<artifactId>spring-security-crypto</artifactId> <artifactId>spring-security-crypto</artifactId>
</dependency> </dependency>
<!-- JWT/JWS support without enabling full Spring Security auto-flow -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) --> <!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
@@ -63,6 +69,13 @@
<artifactId>opentelemetry-api</artifactId> <artifactId>opentelemetry-api</artifactId>
<version>1.49.0</version> <version>1.49.0</version>
</dependency> </dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,33 +0,0 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.User;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AuthSessionService {
private final Map<String, AuthenticatedUser> sessions = new ConcurrentHashMap<>();
public String createSession(User user) {
String token = UUID.randomUUID().toString();
sessions.put(token, new AuthenticatedUser(
user.getId(),
user.getUsername(),
user.getRole(),
user.getDepartmentId()
));
return token;
}
public Optional<AuthenticatedUser> findByToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(sessions.get(token));
}
}

View File

@@ -15,11 +15,11 @@ import java.util.Map;
@Component @Component
public class AuthorizationInterceptor implements HandlerInterceptor { public class AuthorizationInterceptor implements HandlerInterceptor {
private final AuthSessionService sessionService; private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) { public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
this.sessionService = sessionService; this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -28,12 +28,15 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true; return true;
} }
if (request.getRequestURI().equals("/api/auth/login")) { if (isPublicAuthEndpoint(request)) {
return true; return true;
} }
String token = bearerToken(request.getHeader("Authorization")); String token = bearerToken(request.getHeader("Authorization"));
AuthenticatedUser user = sessionService.findByToken(token).orElse(null); AuthenticatedUser user = jwtTokenService.authenticate(
token,
com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
).orElse(null);
if (user == null) { if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему"); writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false; return false;
@@ -49,6 +52,16 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
return true; return true;
} }
private boolean isPublicAuthEndpoint(HttpServletRequest request) {
if (!request.getRequestURI().startsWith("/api/auth/")) {
return false;
}
return "POST".equalsIgnoreCase(request.getMethod())
&& (request.getRequestURI().equals("/api/auth/login")
|| request.getRequestURI().equals("/api/auth/refresh")
|| request.getRequestURI().equals("/api/auth/logout"));
}
@Override @Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear(); AuthContext.clear();

View File

@@ -0,0 +1,66 @@
package com.magistr.app.config.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
private Duration accessTtl = Duration.ofMinutes(15);
private Duration refreshTtl = Duration.ofDays(7);
private String refreshCookieName = "magistr_refresh";
private boolean refreshCookieSecure = false;
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public Duration getAccessTtl() {
return accessTtl;
}
public void setAccessTtl(Duration accessTtl) {
this.accessTtl = accessTtl;
}
public Duration getRefreshTtl() {
return refreshTtl;
}
public void setRefreshTtl(Duration refreshTtl) {
this.refreshTtl = refreshTtl;
}
public String getRefreshCookieName() {
return refreshCookieName;
}
public void setRefreshCookieName(String refreshCookieName) {
this.refreshCookieName = refreshCookieName;
}
public boolean isRefreshCookieSecure() {
return refreshCookieSecure;
}
public void setRefreshCookieSecure(boolean refreshCookieSecure) {
this.refreshCookieSecure = refreshCookieSecure;
}
public byte[] secretBytes() {
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
}
return bytes;
}
}

View File

@@ -0,0 +1,110 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Service
public class JwtTokenService {
private static final String ISSUER = "magistr";
private final JwtProperties properties;
private final JwtEncoder encoder;
private final JwtDecoder decoder;
public JwtTokenService(JwtProperties properties) {
this.properties = properties;
SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256");
this.encoder = new NimbusJwtEncoder(new ImmutableSecret<SecurityContext>(secretKey));
this.decoder = NimbusJwtDecoder
.withSecretKey(secretKey)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
public String createAccessToken(User user, String tenant) {
return createAccessToken(new AuthenticatedUser(
user.getId(),
user.getUsername(),
user.getRole(),
user.getDepartmentId()
), tenant);
}
public String createAccessToken(AuthenticatedUser user, String tenant) {
Instant now = Instant.now();
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
.issuer(ISSUER)
.issuedAt(now)
.expiresAt(now.plus(properties.getAccessTtl()))
.subject(String.valueOf(user.id()))
.id(UUID.randomUUID().toString())
.claim("tenant", tenant)
.claim("userId", user.id())
.claim("username", user.username())
.claim("role", user.role().name());
if (user.departmentId() != null) {
claims.claim("departmentId", user.departmentId());
}
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue();
}
public Optional<AuthenticatedUser> authenticate(String token, String expectedTenant) {
if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) {
return Optional.empty();
}
try {
Jwt jwt = decoder.decode(token);
String tenant = jwt.getClaimAsString("tenant");
if (!expectedTenant.equalsIgnoreCase(tenant)) {
return Optional.empty();
}
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
String username = jwt.getClaimAsString("username");
Role role = Role.valueOf(jwt.getClaimAsString("role"));
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
} catch (JwtException | IllegalArgumentException e) {
return Optional.empty();
}
}
private Optional<Long> asLong(Object value) {
if (value == null) {
return Optional.empty();
}
if (value instanceof Number number) {
return Optional.of(number.longValue());
}
try {
return Optional.of(Long.valueOf(String.valueOf(value)));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.User;
public record RefreshTokenRotation(User user, String refreshToken) {
}

View File

@@ -0,0 +1,132 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
@Service
public class RefreshTokenService {
private static final int TOKEN_BYTES = 32;
private static final int USER_AGENT_LIMIT = 512;
private static final int IP_LIMIT = 64;
private final SecureRandom secureRandom = new SecureRandom();
private final AuthRefreshTokenRepository repository;
private final JwtProperties properties;
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
this.repository = repository;
this.properties = properties;
}
@Transactional
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
String refreshToken = generateRawToken();
AuthRefreshToken token = new AuthRefreshToken();
LocalDateTime now = LocalDateTime.now();
token.setUser(user);
token.setTenant(tenant);
token.setTokenHash(hashToken(refreshToken));
token.setIssuedAt(now);
token.setExpiresAt(now.plus(properties.getRefreshTtl()));
token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
token.setIpAddress(limit(ipAddress, IP_LIMIT));
repository.save(token);
return refreshToken;
}
@Transactional
public Optional<RefreshTokenRotation> rotate(String rawToken, String tenant, String userAgent, String ipAddress) {
if (rawToken == null || rawToken.isBlank()) {
return Optional.empty();
}
LocalDateTime now = LocalDateTime.now();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
if (storedOpt.isEmpty()) {
return Optional.empty();
}
AuthRefreshToken stored = storedOpt.get();
if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) {
return Optional.empty();
}
User user = stored.getUser();
if (user.isArchivedRecord()) {
stored.setRevokedAt(now);
repository.save(stored);
return Optional.empty();
}
String newRawToken = generateRawToken();
String newHash = hashToken(newRawToken);
stored.setRevokedAt(now);
stored.setRotatedToTokenHash(newHash);
repository.save(stored);
AuthRefreshToken next = new AuthRefreshToken();
next.setUser(user);
next.setTenant(tenant);
next.setTokenHash(newHash);
next.setIssuedAt(now);
next.setExpiresAt(now.plus(properties.getRefreshTtl()));
next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
next.setIpAddress(limit(ipAddress, IP_LIMIT));
repository.save(next);
return Optional.of(new RefreshTokenRotation(user, newRawToken));
}
@Transactional
public void revoke(String rawToken, String tenant) {
if (rawToken == null || rawToken.isBlank()) {
return;
}
repository.findByTokenHash(hashToken(rawToken))
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
.filter(token -> token.getRevokedAt() == null)
.ifPresent(token -> {
token.setRevokedAt(LocalDateTime.now());
repository.save(token);
});
}
String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен", e);
}
}
private String generateRawToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String limit(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String trimmed = value.trim();
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
}
}

View File

@@ -1,12 +1,10 @@
package com.magistr.app.config.tenant; package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -43,7 +41,7 @@ public class ConfigMapUpdater {
public ConfigMapUpdater() { public ConfigMapUpdater() {
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH)); this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
if (!runningInK8s) { if (!runningInK8s) {
log.info("Not running in K8s — ConfigMap updates will be skipped"); log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
} }
} }
@@ -53,7 +51,7 @@ public class ConfigMapUpdater {
*/ */
public boolean updateTenantsConfig(List<TenantConfig> tenants) { public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInK8s) { if (!runningInK8s) {
log.warn("Not in K8s, skipping ConfigMap update"); log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
return true; return true;
} }
@@ -87,15 +85,15 @@ public class ConfigMapUpdater {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size()); log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
return true; return true;
} else { } else {
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false; return false;
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error updating ConfigMap: {}", e.getMessage()); log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
return false; return false;
} }
} }
@@ -120,7 +118,7 @@ public class ConfigMapUpdater {
.sslContext(sslContext) .sslContext(sslContext)
.build(); .build();
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to create insecure client, using default: {}", e.getMessage()); log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
return HttpClient.newHttpClient(); return HttpClient.newHttpClient();
} }
} }

View File

@@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.sql.Connection; import java.security.MessageDigest;
import java.sql.ResultSet; import java.security.NoSuchAlgorithmException;
import java.sql.Statement; import java.util.HexFormat;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -58,20 +55,20 @@ public class TenantConfigWatcher {
if (!file.exists()) return; if (!file.exists()) return;
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
String hash = Integer.toHexString(content.hashCode()); String hash = configHash(content);
if (hash.equals(lastConfigHash)) { if (hash.equals(lastConfigHash)) {
return; // Ничего не изменилось return; // Ничего не изменилось
} }
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash); log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
lastConfigHash = hash; lastConfigHash = hash;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {}); List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants); syncTenants(newTenants);
} catch (Exception e) { } catch (Exception e) {
log.error("Error watching tenants config: {}", e.getMessage()); log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
} }
} }
@@ -83,10 +80,19 @@ public class TenantConfigWatcher {
File file = new File(tenantsConfigPath); File file = new File(tenantsConfigPath);
if (file.exists()) { if (file.exists()) {
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
lastConfigHash = Integer.toHexString(content.hashCode()); lastConfigHash = configHash(content);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to refresh config hash: {}", e.getMessage()); log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
}
}
static String configHash(String content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
} }
} }
@@ -103,7 +109,7 @@ public class TenantConfigWatcher {
for (TenantConfig tenant : newTenants) { for (TenantConfig tenant : newTenants) {
String domain = tenant.getDomain().toLowerCase(); String domain = tenant.getDomain().toLowerCase();
if (!current.containsKey(domain)) { if (!current.containsKey(domain)) {
log.info("Adding new tenant '{}' from ConfigMap update", domain); log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
routingDataSource.addTenant(tenant); routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта // Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant); initDatabaseForTenant(tenant);
@@ -113,7 +119,7 @@ public class TenantConfigWatcher {
// Удалить тенанты, которых больше нет в конфиге // Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) { for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) { if (!newDomains.contains(existingDomain)) {
log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain); log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
routingDataSource.removeTenant(existingDomain); routingDataSource.removeTenant(existingDomain);
} }
} }
@@ -129,7 +135,7 @@ public class TenantConfigWatcher {
try { try {
TenantContext.setCurrentTenant(domain); TenantContext.setCurrentTenant(domain);
log.info("[{}] Starting Flyway migrations...", domain); log.info("[{}] Запускаем миграции Flyway", domain);
// Получаем DataSource конкретно для этого тенанта // Получаем DataSource конкретно для этого тенанта
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain); javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
@@ -145,10 +151,10 @@ public class TenantConfigWatcher {
.load(); .load();
flyway.migrate(); flyway.migrate();
log.info("[{}] Flyway migrations completed successfully", domain); log.info("[{}] Миграции Flyway успешно выполнены", domain);
} catch (Exception e) { } catch (Exception e) {
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage()); log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
} finally { } finally {
TenantContext.clear(); TenantContext.clear();
} }

View File

@@ -2,7 +2,6 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -13,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource; import javax.sql.DataSource;
@@ -30,7 +27,7 @@ import java.util.*;
* как заглушку, чтобы Spring JPA мог инициализироваться. * как заглушку, чтобы Spring JPA мог инициализироваться.
*/ */
@Configuration @Configuration
public class TenantDataSourceConfig implements WebMvcConfigurer { public class TenantDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class); private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
@@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
try { try {
routingDataSource.addTenant(tenant); routingDataSource.addTenant(tenant);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage()); log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
} }
} }
@@ -120,27 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
return new JpaTransactionManager(emf); return new JpaTransactionManager(emf);
} }
@org.springframework.context.annotation.Lazy
@org.springframework.beans.factory.annotation.Autowired
private TenantRoutingDataSource tenantRoutingDataSource;
@org.springframework.beans.factory.annotation.Autowired
private AuthorizationInterceptor authorizationInterceptor;
@Bean
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
TenantInterceptor interceptor = new TenantInterceptor();
interceptor.setRoutingDataSource(routingDataSource);
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
}
private List<TenantConfig> loadTenantsFromFile() { private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath); File file = new File(tenantsConfigPath);
if (!file.exists()) { if (!file.exists()) {
@@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath); log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
return list; return list;
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to read tenants config: {}", e.getMessage()); log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
return new ArrayList<>(); return new ArrayList<>();
} }
} }

View File

@@ -0,0 +1,33 @@
package com.magistr.app.config.tenant;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class TenantWebMvcConfig implements WebMvcConfigurer {
private final TenantRoutingDataSource tenantRoutingDataSource;
private final AuthorizationInterceptor authorizationInterceptor;
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
AuthorizationInterceptor authorizationInterceptor) {
this.tenantRoutingDataSource = tenantRoutingDataSource;
this.authorizationInterceptor = authorizationInterceptor;
}
@Bean
public TenantInterceptor tenantInterceptor() {
TenantInterceptor interceptor = new TenantInterceptor();
interceptor.setRoutingDataSource(tenantRoutingDataSource);
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
}
}

View File

@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin/academic-calendars") @RequestMapping("/api/admin/academic-calendars")
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository; private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository; private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository, public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository, SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository, EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarDayRepository = calendarDayRepository;
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository; this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository; this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
} }
@@ -136,6 +146,35 @@ public class AcademicCalendarController {
} }
} }
@GetMapping("/{id}/subjects")
public ResponseEntity<?> getSubjects(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
}
@PutMapping("/{id}/subjects")
@Transactional
public ResponseEntity<?> saveSubjects(@PathVariable Long id,
@RequestBody List<AcademicCalendarSubjectDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) { private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика"); throw new IllegalArgumentException("Передайте данные календарного графика");
@@ -146,6 +185,9 @@ public class AcademicCalendarController {
if (request.courseCount() == null || request.courseCount() <= 0) { if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
} }
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) { || request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
@@ -209,6 +251,53 @@ public class AcademicCalendarController {
throw new IllegalArgumentException("Код активности обязателен"); throw new IllegalArgumentException("Код активности обязателен");
} }
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
}
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
Set<Long> subjectIds = new HashSet<>();
Set<String> uniqueRows = new HashSet<>();
for (AcademicCalendarSubjectDto row : rows) {
if (row == null || row.semesterNumber() == null || row.subjectId() == null) {
throw new IllegalArgumentException("Семестр и дисциплина обязательны");
}
if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) {
throw new IllegalArgumentException("Номер семестра выходит за пределы графика");
}
if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) {
throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру");
}
subjectIds.add(row.subjectId());
}
Map<Long, Subject> subjectsById = subjectRepository.findAllById(subjectIds).stream()
.collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new));
for (Long subjectId : subjectIds) {
Subject subject = subjectsById.get(subjectId);
if (subject == null) {
throw new IllegalArgumentException("Дисциплина не найдена");
}
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику");
}
}
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
calendarSubjectRepository.flush();
List<AcademicCalendarSubject> entities = rows.stream()
.map(row -> {
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
calendarSubject.setAcademicCalendar(calendar);
calendarSubject.setSemesterNumber(row.semesterNumber());
calendarSubject.setSubject(subjectsById.get(row.subjectId()));
return calendarSubject;
})
.toList();
calendarSubjectRepository.saveAll(entities);
}
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) { private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
AcademicYear year = calendar.getAcademicYear(); AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality(); Speciality speciality = calendar.getSpeciality();
@@ -247,4 +336,17 @@ public class AcademicCalendarController {
activityType.getAllowSchedule() activityType.getAllowSchedule()
); );
} }
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
} }

View File

@@ -1,15 +1,25 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.JwtProperties;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.auth.RefreshTokenRotation;
import com.magistr.app.config.auth.RefreshTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest; import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse; import com.magistr.app.dto.LoginResponse;
import com.magistr.app.config.auth.AuthSessionService;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -19,7 +29,9 @@ public class AuthController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
private final AuthSessionService sessionService; private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
private static final Map<String, String> ROLE_REDIRECTS = Map.of( private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/", "ADMIN", "/admin/",
@@ -32,14 +44,18 @@ public class AuthController {
public AuthController(UserRepository userRepository, public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder, BCryptPasswordEncoder passwordEncoder,
AuthSessionService sessionService) { JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.sessionService = sessionService; this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) { public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername()); Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() || if (userOpt.isEmpty() ||
@@ -55,12 +71,52 @@ public class AuthController {
.status(401) .status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
} }
String token = sessionService.createSession(user); String tenant = TenantContext.getCurrentTenant();
String roleName = user.getRole().name(); String accessToken = jwtTokenService.createAccessToken(user, tenant);
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); String refreshToken = refreshTokenService.createSession(
Long departmentId = user.getDepartmentId(); user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIp(servletRequest)
);
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
.body(loginResponse(user, accessToken));
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(HttpServletRequest request) {
Optional<String> refreshToken = refreshCookieValue(request);
if (refreshToken.isEmpty()) {
return unauthorized();
}
Optional<RefreshTokenRotation> rotation = refreshTokenService.rotate(
refreshToken.get(),
TenantContext.getCurrentTenant(),
request.getHeader("User-Agent"),
clientIp(request)
);
if (rotation.isEmpty()) {
return unauthorizedWithClearedCookie();
}
User user = rotation.get().user();
String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString())
.body(loginResponse(user, accessToken));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
refreshCookieValue(request).ifPresent(token ->
refreshTokenService.revoke(token, TenantContext.getCurrentTenant())
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
.body(Map.of("success", true, "message", "Выход выполнен"));
} }
@GetMapping("/me") @GetMapping("/me")
@@ -69,11 +125,78 @@ public class AuthController {
if (user == null) { if (user == null) {
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему")); return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
} }
return ResponseEntity.ok(Map.of( Map<String, Object> response = new LinkedHashMap<>();
"userId", user.id(), response.put("userId", user.id());
"username", user.username(), response.put("username", user.username());
"role", user.role().name(), response.put("role", user.role().name());
"departmentId", user.departmentId() response.put("departmentId", user.departmentId());
)); return ResponseEntity.ok(response);
}
private LoginResponse loginResponse(User user, String token) {
String roleName = user.getRole().name();
return new LoginResponse(
true,
"OK",
token,
roleName,
ROLE_REDIRECTS.getOrDefault(roleName, "/"),
user.getDepartmentId(),
user.getId()
);
}
private ResponseEntity<LoginResponse> unauthorized() {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
}
private ResponseEntity<LoginResponse> unauthorizedWithClearedCookie() {
return ResponseEntity
.status(401)
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
}
private ResponseCookie refreshCookie(String token) {
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token)
.httpOnly(true)
.secure(jwtProperties.isRefreshCookieSecure())
.sameSite("Lax")
.path("/api/auth")
.maxAge(jwtProperties.getRefreshTtl())
.build();
}
private ResponseCookie clearRefreshCookie() {
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "")
.httpOnly(true)
.secure(jwtProperties.isRefreshCookieSecure())
.sameSite("Lax")
.path("/api/auth")
.maxAge(Duration.ZERO)
.build();
}
private Optional<String> refreshCookieValue(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank());
}
}
return Optional.empty();
}
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
} }
} }

View File

@@ -2,38 +2,58 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto; import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
@RestController @RestController
@RequestMapping("/api/department") @RequestMapping("/api/department")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) @RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController { public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository; private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository, public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository, SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository, UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) { ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository; this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService; this.scheduleQueryService = scheduleQueryService;
} }
@@ -108,9 +128,110 @@ public class DepartmentWorkspaceController {
} }
@GetMapping("/teachers") @GetMapping("/teachers")
public List<?> getTeachers(@RequestParam(required = false) Long departmentId) { public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId); Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.toList();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return teachersById.values().stream()
.map(this::toUserResponse)
.toList();
}
@PostMapping("/teachers/{teacherId}/assignments")
@Transactional
public ResponseEntity<?> addTeacherToDepartment(@PathVariable Long teacherId,
@RequestBody(required = false) Map<String, Object> request) {
Long requestedDepartmentId = asLong(request == null ? null : request.get("departmentId"));
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = userRepository.findById(teacherId).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = LocalDate.now();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(today);
assignment.setPrimaryAssignment(false);
assignment.setComment(trimToNull(request == null ? null : request.get("comment")));
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
return ResponseEntity.ok(toAssignmentMap(assignment));
}
@GetMapping("/teacher-requests")
public List<TeacherCreationRequestResponse> getTeacherRequests(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
return teacherCreationRequestRepository.findByDepartment_IdOrderByCreatedAtDesc(effectiveDepartmentId).stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
@PostMapping("/teacher-requests")
public ResponseEntity<?> createTeacherRequest(@RequestBody CreateTeacherCreationRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные заявки обязательны"));
}
Long departmentId = effectiveDepartmentId(request == null ? null : request.getDepartmentId());
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
String username = trimToNull(request.getUsername());
String fullName = trimToNull(request.getFullName());
String jobTitle = trimToNull(request.getJobTitle());
if (username == null || fullName == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин и ФИО преподавателя обязательны"));
}
if (username.length() > 50 || fullName.length() > 255 || (jobTitle != null && jobTitle.length() > 255)) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей заявки"));
}
if (userRepository.findByUsername(username).isPresent()
|| teacherCreationRequestRepository.existsByUsernameAndStatus(username, TeacherCreationRequestStatus.PENDING)) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин уже занят или ожидает одобрения"));
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
teacherRequest.setJobTitle(jobTitle == null ? "Не указано" : jobTitle);
teacherRequest.setComment(trimToNull(request.getComment()));
teacherRequest.setRequestedBy(AuthContext.currentUserId());
return ResponseEntity.ok(toTeacherCreationRequestResponse(teacherCreationRequestRepository.save(teacherRequest)));
} }
@GetMapping("/schedule") @GetMapping("/schedule")
@@ -136,10 +257,77 @@ public class DepartmentWorkspaceController {
); );
} }
private UserResponse toUserResponse(User user) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
response.setStatus(user.getStatus());
return response;
}
private TeacherCreationRequestResponse toTeacherCreationRequestResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private Map<String, Object> toAssignmentMap(TeacherDepartmentAssignment assignment) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", assignment.getId());
response.put("teacherId", assignment.getTeacher().getId());
response.put("teacherName", assignment.getTeacher().getFullName());
response.put("departmentId", assignment.getDepartment().getId());
response.put("departmentName", assignment.getDepartment().getDepartmentName());
response.put("validFrom", assignment.getValidFrom());
response.put("primaryAssignment", assignment.getPrimaryAssignment());
return response;
}
private Long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && !text.isBlank()) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) { private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser(); var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) { if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId(); return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
} }
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId; return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
} }
@@ -148,6 +336,6 @@ public class DepartmentWorkspaceController {
var user = AuthContext.getCurrentUser(); var user = AuthContext.getCurrentUser();
return user == null return user == null
|| user.role() == Role.ADMIN || user.role() == Role.ADMIN
|| (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId())); || (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
} }
} }

View File

@@ -0,0 +1,76 @@
package com.magistr.app.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Некорректный запрос"
: exception.getMessage();
return error(HttpStatus.BAD_REQUEST, message, request);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Запрошенная запись не найдена"
: exception.getMessage();
return error(HttpStatus.NOT_FOUND, message, request);
}
@ExceptionHandler({
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
HttpServletRequest request) {
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
HttpServletRequest request) {
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", status.value());
body.put("error", reason(status));
body.put("message", message);
body.put("path", request.getRequestURI());
return ResponseEntity.status(status).body(body);
}
private String reason(HttpStatus status) {
return switch (status) {
case BAD_REQUEST -> "Некорректный запрос";
case NOT_FOUND -> "Не найдено";
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
default -> status.name();
};
}
}

View File

@@ -1,20 +1,24 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse; import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.Subject;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.utils.CourseAndSemesterCalculator; import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -22,9 +26,13 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/groups") @RequestMapping("/api/groups")
@@ -39,8 +47,10 @@ public class GroupController {
private final SpecialtyProfileRepository specialtyProfileRepository; private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository; private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
public GroupController(GroupRepository groupRepository, public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository, EducationFormRepository educationFormRepository,
@@ -48,16 +58,20 @@ public class GroupController {
SpecialtyProfileRepository specialtyProfileRepository, SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository, AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository, StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository; this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository; this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository; this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository; this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
} }
@GetMapping @GetMapping
@@ -67,7 +81,9 @@ public class GroupController {
try { try {
List<StudentGroup> groups = includeArchived List<StudentGroup> groups = includeArchived
? groupRepository.findAll() ? groupRepository.findAll()
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); : groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
List<GroupResponse> response = groups.stream() List<GroupResponse> response = groups.stream()
.map(this::mapToResponse) .map(this::mapToResponse)
@@ -84,7 +100,9 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) { public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try { try {
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
if(groups.isEmpty()) { if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId); logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -264,6 +282,8 @@ public class GroupController {
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
Speciality speciality = group.getSpeciality(); Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile(); SpecialtyProfile profile = group.getSpecialtyProfile();
LocalDate today = LocalDate.now();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse( return new GroupResponse(
group.getId(), group.getId(),
group.getName(), group.getName(),
@@ -278,7 +298,11 @@ public class GroupController {
speciality.getSpecialityCode(), speciality.getSpecialityCode(),
speciality.getSpecialityName(), speciality.getSpecialityName(),
profile.getId(), profile.getId(),
profile.getName() profile.getName(),
group.getStatus(),
groupLifecycleService.isAvailableForSelection(group, today),
studyState.name(),
studyState.getLabel()
); );
} }
@@ -288,8 +312,10 @@ public class GroupController {
if (!groupRepository.existsById(id)) { if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream() List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
.map(this::toAssignmentDto) Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
return ResponseEntity.ok(assignments.stream()
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
.toList()); .toList());
} }
@@ -330,7 +356,13 @@ public class GroupController {
assignment.setAcademicYear(academicYear); assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar); assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment))); StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(calendar.getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
} }
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}") @DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@@ -345,7 +377,31 @@ public class GroupController {
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено")); return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
} }
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) { private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
List<StudentGroupCalendarAssignment> assignments) {
Set<Long> calendarIds = assignments.stream()
.map(assignment -> assignment.getAcademicCalendar().getId())
.collect(Collectors.toSet());
if (calendarIds.isEmpty()) {
return Collections.emptyMap();
}
return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream()
.collect(Collectors.groupingBy(
calendarSubject -> calendarSubject.getAcademicCalendar().getId(),
Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList())
));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
return toAssignmentDto(
assignment,
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
);
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
List<AcademicCalendarSubjectDto> subjects) {
return new GroupCalendarAssignmentDto( return new GroupCalendarAssignmentDto(
assignment.getId(), assignment.getId(),
assignment.getStudentGroup().getId(), assignment.getStudentGroup().getId(),
@@ -353,7 +409,21 @@ public class GroupController {
assignment.getAcademicYear().getId(), assignment.getAcademicYear().getId(),
assignment.getAcademicYear().getTitle(), assignment.getAcademicYear().getTitle(),
assignment.getAcademicCalendar().getId(), assignment.getAcademicCalendar().getId(),
assignment.getAcademicCalendar().getTitle() assignment.getAcademicCalendar().getTitle(),
subjects
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
); );
} }
} }

View File

@@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@@ -42,27 +41,17 @@ public class ScheduleController {
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
} }
try { return ResponseEntity.ok(scheduleQueryService.search(
List<RenderedLessonDto> lessons = scheduleQueryService.search( groupId,
groupId, teacherId,
teacherId, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, startDate,
startDate, endDate
endDate ));
);
return ResponseEntity.ok(lessons);
} catch (IllegalArgumentException e) {
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("message", "Произошла ошибка при генерации расписания"));
}
} }
} }

View File

@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
@RestController @RestController
@@ -19,6 +21,12 @@ import java.util.*;
public class ScheduleRuleAdminController { public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT"; private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleRepository scheduleRuleRepository; private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
try { try {
ScheduleRule rule = new ScheduleRule(); ScheduleRule rule = new ScheduleRule();
applyRule(rule, request); applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved)); return ResponseEntity.ok(toDto(saved));
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
} }
try { try {
ScheduleRule candidate = new ScheduleRule();
applyRule(candidate, request);
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
if (conflict != null) {
return conflictResponse(conflict);
}
applyRule(rule, request); applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
rule.getSlots().addAll(slots); rule.getSlots().addAll(slots);
} }
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"message", "Невозможно сохранить правило: слот занят",
"conflictRule", toDto(conflict.rule()),
"conflictFields", conflict.fields().stream().toList(),
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
));
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
default -> field;
};
}
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
.orElse(null);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> newSlots,
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot newSlot : newSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(newSlot, existingSlot)
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
fields.addAll(conflictFields(newSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> firstGroupIds = groupIds(first);
Set<Long> secondGroupIds = groupIds(second);
firstGroupIds.retainAll(secondGroupIds);
for (Long groupId : firstGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
firstSubgroupIds.retainAll(secondSubgroupIds);
if (!firstSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
Set<Long> ids = new HashSet<>();
if (slot.getScheduleRule() == null) {
return ids;
}
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
ids.add(group.getId());
}
return ids;
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return subgroupIdsForGroup(slot, groupId).isEmpty();
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
Set<Long> ids = new HashSet<>();
for (Subgroup subgroup : slot.getSubgroups()) {
if (subgroup.getStudentGroup() != null
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
ids.add(subgroup.getId());
}
}
return ids;
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7"); throw new IllegalArgumentException("День недели должен быть от 1 до 7");
} }
if (slotDto.parity() == null) { if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели обязательна"); throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
} }
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
.thenComparing(Subgroup::getName)) .thenComparing(Subgroup::getName))
.toList(); .toList();
} }
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
} }

View File

@@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/schedule/search") @RequestMapping("/api/schedule/search")
@@ -40,24 +39,17 @@ public class ScheduleSearchController {
) { ) {
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}", logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
groupId, teacherId, classroomId, departmentId, startDate, endDate); groupId, teacherId, classroomId, departmentId, startDate, endDate);
try { return ResponseEntity.ok(scheduleQueryService.search(
return ResponseEntity.ok(scheduleQueryService.search( groupId,
groupId, teacherId,
teacherId, classroomId,
classroomId, departmentId,
departmentId, subjectId,
subjectId, lessonTypeId,
lessonTypeId, timeSlotId,
timeSlotId, parity,
parity, startDate,
startDate, endDate
endDate ));
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
}
} }
} }

View File

@@ -193,6 +193,16 @@ public class SpecialityController {
)); ));
} }
@GetMapping("/profiles")
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
.filter(profile -> profile.getSpeciality().isActiveRecord())
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
.map(this::toProfileDto)
.toList();
}
@GetMapping("/{specialtyId}/profiles") @GetMapping("/{specialtyId}/profiles")
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) { public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) { if (!specialtiesRepository.existsById(specialtyId)) {

View File

@@ -66,6 +66,10 @@ public class SubgroupController {
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
} }
String capacityError = validateGroupCapacity(group, null, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
Subgroup subgroup = new Subgroup(); Subgroup subgroup = new Subgroup();
subgroup.setStudentGroup(group); subgroup.setStudentGroup(group);
@@ -95,6 +99,10 @@ public class SubgroupController {
if (duplicateName) { if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
} }
String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
subgroup.setName(newName); subgroup.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity()); subgroup.setStudentCapacity(request.studentCapacity());
@@ -113,6 +121,10 @@ public class SubgroupController {
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
} }
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try { try {
subgroup.archive("Подгруппа архивирована"); subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup); subgroupRepository.save(subgroup);
@@ -127,8 +139,39 @@ public class SubgroupController {
if (request == null || request.name() == null || request.name().isBlank()) { if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно"; return "Название подгруппы обязательно";
} }
if (request.studentCapacity() != null && request.studentCapacity() < 0) { if (request.studentCapacity() == null) {
return "Численность подгруппы не может быть отрицательной"; return "Численность подгруппы обязательна";
}
if (request.studentCapacity() <= 0) {
return "Численность подгруппы должна быть больше 0";
}
return null;
}
private String validateGroupCapacity(StudentGroup group, Long excludedSubgroupId, Integer requestedCapacity) {
Long currentCapacity = excludedSubgroupId == null
? subgroupRepository.sumActiveStudentCapacityByGroupId(group.getId())
: subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(group.getId(), excludedSubgroupId);
long totalCapacity = currentCapacity + requestedCapacity.longValue();
if (totalCapacity > group.getGroupSize()) {
return "Сумма численностей подгрупп не может превышать численность группы (" + group.getGroupSize() + " чел.)";
}
return null;
}
private String validateDeleteKeepsCompleteSplit(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
long remainingCount = subgroupRepository.countActiveByGroupIdExcludingId(group.getId(), subgroup.getId());
if (remainingCount == 0) {
return null;
}
Long remainingCapacity = subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(
group.getId(),
subgroup.getId()
);
if (remainingCapacity < group.getGroupSize()) {
return "Нельзя удалить одну подгруппу из деления: измените количество подгрупп в форме настройки или отключите деление";
} }
return null; return null;
} }

View File

@@ -0,0 +1,190 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-requests")
@RequireRoles({Role.ADMIN})
public class TeacherCreationRequestController {
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public List<TeacherCreationRequestResponse> getRequests(@RequestParam(required = false) TeacherCreationRequestStatus status) {
return (status == null
? teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc()
: teacherCreationRequestRepository.findByStatusOrderByCreatedAtDesc(status))
.stream()
.map(this::toResponse)
.toList();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
if (review == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные одобрения обязательны"));
}
String username = firstNotBlank(review.getUsername(), request.getUsername());
String fullName = firstNotBlank(review.getFullName(), request.getFullName());
String jobTitle = firstNotBlank(review.getJobTitle(), request.getJobTitle(), "Не указано");
if (username.length() > 50 || fullName.length() > 255 || jobTitle.length() > 255) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей преподавателя"));
}
if (review.getPassword() == null || review.getPassword().length() < 8) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль преподавателя должен быть не короче 8 символов"));
}
if (userRepository.findByUsername(username).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь с таким логином уже существует"));
}
Long departmentId = review.getDepartmentId() == null ? request.getDepartment().getId() : review.getDepartmentId();
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = new User();
teacher.setUsername(username);
teacher.setPassword(passwordEncoder.encode(review.getPassword()));
teacher.setRole(Role.TEACHER);
teacher.setFullName(fullName);
teacher.setJobTitle(jobTitle);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
request.setUsername(username);
request.setFullName(fullName);
request.setJobTitle(jobTitle);
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
return ResponseEntity.ok(Map.of(
"request", toResponse(request),
"teacher", toUserResponse(teacher, department)
));
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody(required = false) ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}
private TeacherCreationRequestResponse toResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private UserResponse toUserResponse(User user, Department department) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
department.getDepartmentName()
);
response.setDepartmentId(department.getId());
response.setStatus(user.getStatus());
return response;
}
private String firstNotBlank(String... values) {
for (String value : values) {
String normalized = trimToNull(value);
if (normalized != null) {
return normalized;
}
}
return "";
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -18,15 +18,16 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
@@ -52,182 +53,123 @@ public class UserController {
@GetMapping @GetMapping
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) { public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей"); logger.info("Получен запрос на получение всех пользователей");
try { List<User> users = includeArchived
List<User> users = includeArchived ? userRepository.findAll()
? userRepository.findAll() : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List<UserResponse> response = users.stream()
.map(this::toUserResponse)
List<UserResponse> response = users.stream() .toList();
.map(u -> { logger.info("Получено {} пользователей", response.size());
String departmentName = departmentRepository.findById(u.getDepartmentId()) return response;
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse userResponse = new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
})
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
throw e;
}
} }
@GetMapping("/teachers") @GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() { public List<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
try { .map(this::toUserResponse)
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED); .toList();
logger.info("Получено {} преподавателей", response.size());
List<UserResponse> response = users.stream() return response;
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse userResponse = new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
})
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
throw e;
}
} }
@GetMapping("/teachers/{departmentId}") @GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){ public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); if (!canAccessDepartment(departmentId)) {
try { return ResponseEntity.status(HttpStatus.FORBIDDEN)
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot( .body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Преподаватели для указанной кафедры не найдены");
}
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
List<UserResponse> userResponses = users.stream()
.map( user -> {
return new UserResponse(
user.getId(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
}).toList();
return ResponseEntity.ok(userResponses);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка преподавателей");
} }
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Преподаватели для указанной кафедры не найдены");
}
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
return ResponseEntity.ok(users.stream()
.map(this::toUserResponse)
.toList());
} }
@PostMapping @PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request == null) {
String errorMessage = "Данные пользователя обязательны";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId()); logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
try { if (request.getUsername() == null || request.getUsername().isBlank()) {
if (request.getUsername() == null || request.getUsername().isBlank()) { String errorMessage = "Имя пользователя обязательно";
String errorMessage = "Имя пользователя обязательно"; logger.error("Ошибка валидации: {}", errorMessage);
logger.error("Ошибка валидации: {}", errorMessage); return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
String errorMessage = "Пароль минимум 4 символа";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
String errorMessage = "Пользователь уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getFullName() == null || request.getFullName().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
request.setJobTitle("Не указано");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (Exception e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
user.setFullName(request.getFullName());
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
if (department != null) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
}
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
} catch (Exception e ) {
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
} }
if (request.getPassword() == null || request.getPassword().length() < 8) {
String errorMessage = "Пароль минимум 8 символов";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
String errorMessage = "Пользователь уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getFullName() == null || request.getFullName().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
request.setJobTitle("Не указано");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department requestedDepartment = departmentRepository.findById(request.getDepartmentId()).orElse(null);
if (requestedDepartment == null || requestedDepartment.isArchivedRecord()) {
String errorMessage = "Активная кафедра не найдена";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (IllegalArgumentException | NullPointerException e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
user.setFullName(request.getFullName());
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(toUserResponse(user));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@@ -252,8 +194,7 @@ public class UserController {
} }
user.restore(); user.restore();
userRepository.save(user); userRepository.save(user);
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), return ResponseEntity.ok(toUserResponse(user));
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
} }
@GetMapping("/{id}/department-history") @GetMapping("/{id}/department-history")
@@ -262,6 +203,13 @@ public class UserController {
if (!userRepository.existsById(id)) { if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream() return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
.map(this::toAssignmentDto) .map(this::toAssignmentDto)
.toList()); .toList());
@@ -316,17 +264,35 @@ public class UserController {
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId, public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false) @RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? LocalDate.now() : date; LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teacherDepartmentAssignmentRepository return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.findDepartmentTeachersAtDate(departmentId, effectiveDate) .map(this::toUserResponse)
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
.map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
.toList()); .toList());
} }
private UserResponse toUserResponse(User user) {
String departmentName = user.getDepartmentId() == null
? null
: departmentRepository.findById(user.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
departmentName
);
response.setDepartmentId(user.getDepartmentId());
response.setStatus(user.getStatus());
return response;
}
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) { private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
return new TeacherDepartmentAssignmentDto( return new TeacherDepartmentAssignmentDto(
assignment.getId(), assignment.getId(),
@@ -340,4 +306,28 @@ public class UserController {
assignment.getComment() assignment.getComment()
); );
} }
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
Map<Long, User> teachersById = new LinkedHashMap<>();
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
if (assignments != null) {
assignments.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
}
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
if (directTeachers != null) {
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
}
return List.copyOf(teachersById.values());
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null
|| currentUser.role() != Role.DEPARTMENT
|| (departmentId != null && departmentId.equals(currentUser.departmentId()));
}
} }

View File

@@ -128,7 +128,7 @@ public class WorkloadController {
.filter(Objects::nonNull) .filter(Objects::nonNull)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
.filter(Classroom::getIsAvailable) .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.filter(classroom -> !busyClassroomIds.contains(classroom.getId())) .filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
.map(this::toClassroomResponse) .map(this::toClassroomResponse)
.toList(); .toList();

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarSubjectDto(
Long id,
Long calendarId,
Integer semesterNumber,
Long subjectId,
String subjectName,
String subjectCode,
Long departmentId
) {
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
public class CreateTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private String comment;
private Long departmentId;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -1,5 +1,7 @@
package com.magistr.app.dto; package com.magistr.app.dto;
import java.util.List;
public record GroupCalendarAssignmentDto( public record GroupCalendarAssignmentDto(
Long id, Long id,
Long groupId, Long groupId,
@@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto(
Long academicYearId, Long academicYearId,
String academicYearTitle, String academicYearTitle,
Long calendarId, Long calendarId,
String calendarTitle String calendarTitle,
List<AcademicCalendarSubjectDto> subjects
) { ) {
} }

View File

@@ -19,6 +19,10 @@ public class GroupResponse {
private String specialtyName; private String specialtyName;
private Long specialtyProfileId; private Long specialtyProfileId;
private String specialtyProfileName; private String specialtyProfileName;
private String status;
private Boolean active;
private String studyState;
private String studyStateName;
public GroupResponse(Long id, public GroupResponse(Long id,
String name, String name,
@@ -33,7 +37,11 @@ public class GroupResponse {
String specialtyCode, String specialtyCode,
String specialtyName, String specialtyName,
Long specialtyProfileId, Long specialtyProfileId,
String specialtyProfileName) { String specialtyProfileName,
String status,
Boolean active,
String studyState,
String studyStateName) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.groupSize = groupSize; this.groupSize = groupSize;
@@ -48,6 +56,10 @@ public class GroupResponse {
this.specialtyName = specialtyName; this.specialtyName = specialtyName;
this.specialtyProfileId = specialtyProfileId; this.specialtyProfileId = specialtyProfileId;
this.specialtyProfileName = specialtyProfileName; this.specialtyProfileName = specialtyProfileName;
this.status = status;
this.active = active;
this.studyState = studyState;
this.studyStateName = studyStateName;
} }
public Long getId() { public Long getId() {
@@ -109,4 +121,20 @@ public class GroupResponse {
public String getSpecialtyProfileName() { public String getSpecialtyProfileName() {
return specialtyProfileName; return specialtyProfileName;
} }
public String getStatus() {
return status;
}
public Boolean getActive() {
return active;
}
public String getStudyState() {
return studyState;
}
public String getStudyStateName() {
return studyStateName;
}
} }

View File

@@ -36,6 +36,7 @@ public record RenderedLessonDto(
String activityType, String activityType,
Integer lessonTypeAcademicHours, Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson, Integer consumedLessonTypeAcademicHoursBeforeLesson,
Integer remainingLessonTypeAcademicHoursAfterLesson Integer remainingLessonTypeAcademicHoursAfterLesson,
com.magistr.app.model.ScheduleParity ruleParity
) { ) {
} }

View File

@@ -0,0 +1,59 @@
package com.magistr.app.dto;
public class ReviewTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private Long departmentId;
private String password;
private String reviewComment;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
public record TeacherCreationRequestResponse(
Long id,
Long departmentId,
String departmentName,
String username,
String fullName,
String jobTitle,
String comment,
String status,
Long requestedBy,
LocalDateTime createdAt,
Long reviewedBy,
LocalDateTime reviewedAt,
String reviewComment,
Long createdTeacherId
) {
}

View File

@@ -71,6 +71,10 @@ public class UserResponse {
return departmentId; return departmentId;
} }
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getStatus() { public String getStatus() {
return status; return status;
} }

View File

@@ -0,0 +1,65 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(
name = "academic_calendar_subjects",
uniqueConstraints = @UniqueConstraint(
name = "uq_academic_calendar_subject",
columnNames = {"calendar_id", "semester_number", "subject_id"}
),
indexes = {
@Index(name = "idx_academic_calendar_subjects_calendar_semester", columnList = "calendar_id, semester_number"),
@Index(name = "idx_academic_calendar_subjects_subject", columnList = "subject_id")
}
)
public class AcademicCalendarSubject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "calendar_id", nullable = false)
private AcademicCalendar academicCalendar;
@Column(name = "semester_number", nullable = false)
private Integer semesterNumber;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subject_id", nullable = false)
private Subject subject;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public AcademicCalendar getAcademicCalendar() {
return academicCalendar;
}
public void setAcademicCalendar(AcademicCalendar academicCalendar) {
this.academicCalendar = academicCalendar;
}
public Integer getSemesterNumber() {
return semesterNumber;
}
public void setSemesterNumber(Integer semesterNumber) {
this.semesterNumber = semesterNumber;
}
public Subject getSubject() {
return subject;
}
public void setSubject(Subject subject) {
this.subject = subject;
}
}

View File

@@ -0,0 +1,134 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "auth_refresh_tokens")
public class AuthRefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false, length = 100)
private String tenant;
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
private String tokenHash;
@Column(name = "issued_at", nullable = false)
private LocalDateTime issuedAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@Column(name = "rotated_to_token_hash", length = 64)
private String rotatedToTokenHash;
@Column(name = "user_agent", length = 512)
private String userAgent;
@Column(name = "ip_address", length = 64)
private String ipAddress;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getTenant() {
return tenant;
}
public void setTenant(String tenant) {
this.tenant = tenant;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public LocalDateTime getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public LocalDateTime getRevokedAt() {
return revokedAt;
}
public void setRevokedAt(LocalDateTime revokedAt) {
this.revokedAt = revokedAt;
}
public String getRotatedToTokenHash() {
return rotatedToTokenHash;
}
public void setRotatedToTokenHash(String rotatedToTokenHash) {
this.rotatedToTokenHash = rotatedToTokenHash;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
public boolean isActive(LocalDateTime now) {
return revokedAt == null && expiresAt.isAfter(now);
}
}

View File

@@ -83,6 +83,9 @@ public abstract class LifecycleEntity {
if (date == null) { if (date == null) {
return isActiveRecord(); return isActiveRecord();
} }
if (isArchivedRecord() && activeTo == null) {
return false;
}
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo); boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
return afterStart && beforeEnd; return afterStart && beforeEnd;

View File

@@ -21,16 +21,35 @@ public enum ScheduleLessonCategory {
if (lessonType == null || lessonType.getLessonType() == null) { if (lessonType == null || lessonType.getLessonType() == null) {
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
} }
String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU")); String name = lessonType.getLessonType()
if (name.contains("лекц")) { .trim()
.toLowerCase(Locale.forLanguageTag("ru-RU"))
.replace('ё', 'е');
boolean lecture = name.equals("лекция") || name.equals("лекции") || name.startsWith("лекц");
boolean laboratory = name.equals("лабораторная работа")
|| name.equals("лабораторные работы")
|| name.startsWith("лаб")
|| name.contains("лаборатор");
boolean practice = name.equals("практика")
|| name.equals("практики")
|| name.startsWith("практ")
|| name.contains("практичес")
|| name.contains("семинар");
int matches = (lecture ? 1 : 0) + (laboratory ? 1 : 0) + (practice ? 1 : 0);
if (matches > 1) {
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
+ "\" неоднозначен. Используйте отдельный тип: лекция, практика или лабораторная работа");
}
if (lecture) {
return LECTURE; return LECTURE;
} }
if (name.contains("лаб")) { if (laboratory) {
return LABORATORY; return LABORATORY;
} }
if (name.contains("практ")) { if (practice) {
return PRACTICE; return PRACTICE;
} }
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
+ "\" должен быть лекцией, практикой или лабораторной работой");
} }
} }

View File

@@ -1,5 +1,6 @@
package com.magistr.app.model; package com.magistr.app.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDate; import java.time.LocalDate;
@@ -89,7 +90,7 @@ public class ScheduleRule {
} }
public void setStatus(String status) { public void setStatus(String status) {
this.status = status; this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status;
} }
public LocalDate getValidFrom() { public LocalDate getValidFrom() {
@@ -172,6 +173,38 @@ public class ScheduleRule {
}; };
} }
@JsonIgnore
public boolean isActiveRecord() {
return !LifecycleEntity.STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isArchivedRecord() {
return LifecycleEntity.STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isActiveOn(LocalDate date) {
if (date == null) {
return isActiveRecord();
}
if (!isActiveRecord()) {
return false;
}
boolean afterStart = validFrom == null || !date.isBefore(validFrom);
boolean beforeEnd = validTo == null || !date.isAfter(validTo);
return afterStart && beforeEnd;
}
public void archive() {
status = LifecycleEntity.STATUS_ARCHIVED;
}
public void restore() {
status = LifecycleEntity.STATUS_ACTIVE;
validTo = null;
}
private int valueOrZero(Integer value) { private int valueOrZero(Integer value) {
return valueOrDefault(value, 0); return valueOrDefault(value, 0);
} }

View File

@@ -0,0 +1,19 @@
package com.magistr.app.model;
public enum StudentGroupStudyState {
ACTIVE("Активна"),
NOT_STARTED("Обучение не началось"),
GRADUATED("Завершила обучение"),
INACTIVE("Неактивна"),
ARCHIVED("Архив");
private final String label;
StudentGroupStudyState(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}

View File

@@ -0,0 +1,168 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "teacher_creation_requests")
public class TeacherCreationRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
@Column(nullable = false, length = 50)
private String username;
@Column(name = "full_name", nullable = false, length = 255)
private String fullName;
@Column(name = "job_title", nullable = false, length = 255)
private String jobTitle;
@Column(columnDefinition = "TEXT")
private String comment;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TeacherCreationRequestStatus status = TeacherCreationRequestStatus.PENDING;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "created_teacher_id")
private Long createdTeacherId;
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public TeacherCreationRequestStatus getStatus() {
return status;
}
public void setStatus(TeacherCreationRequestStatus status) {
this.status = status == null ? TeacherCreationRequestStatus.PENDING : status;
}
public Long getRequestedBy() {
return requestedBy;
}
public void setRequestedBy(Long requestedBy) {
this.requestedBy = requestedBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Long getReviewedBy() {
return reviewedBy;
}
public void setReviewedBy(Long reviewedBy) {
this.reviewedBy = reviewedBy;
}
public LocalDateTime getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
public Long getCreatedTeacherId() {
return createdTeacherId;
}
public void setCreatedTeacherId(Long createdTeacherId) {
this.createdTeacherId = createdTeacherId;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum TeacherCreationRequestStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -0,0 +1,34 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendarSubject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface AcademicCalendarSubjectRepository extends JpaRepository<AcademicCalendarSubject, Long> {
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id = :calendarId
order by calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdWithSubject(@Param("calendarId") Long calendarId);
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id in :calendarIds
order by calendar.id asc, calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
void deleteByAcademicCalendar_Id(Long calendarId);
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthRefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
}

View File

@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
and subgroup.status <> 'ARCHIVED' and subgroup.status <> 'ARCHIVED'
""") """)
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
""")
Long sumActiveStudentCapacityByGroupId(@Param("groupId") Long groupId);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
Long sumActiveStudentCapacityByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
@Query("""
select count(subgroup)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
} }

View File

@@ -0,0 +1,18 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
List<TeacherCreationRequest> findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status);
List<TeacherCreationRequest> findAllByOrderByCreatedAtDesc();
boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status);
}

View File

@@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
join fetch a.teacher join fetch a.teacher
join fetch a.department join fetch a.department
where a.department.id = :departmentId where a.department.id = :departmentId
and a.primaryAssignment = true
and a.validFrom <= :date and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date) and (a.validTo is null or a.validTo >= :date)
order by a.teacher.fullName order by a.teacher.fullName
@@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
@Param("departmentId") Long departmentId, @Param("departmentId") Long departmentId,
@Param("date") LocalDate date @Param("date") LocalDate date
); );
@Query("""
select count(a) > 0
from TeacherDepartmentAssignment a
where a.teacher.id = :teacherId
and a.department.id = :departmentId
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
""")
boolean existsActiveAssignment(
@Param("teacherId") Long teacherId,
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
} }

View File

@@ -16,7 +16,6 @@ import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.TextStyle; import java.time.format.TextStyle;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -34,7 +33,6 @@ public class ScheduleGeneratorService {
private final TimeSlotRepository timeSlotRepository; private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository, GroupRepository groupRepository,
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); .orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>(); Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>(); List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> warnedAcademicYears = new HashSet<>(); Set<Long> warnedAcademicYears = new HashSet<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date); Optional<Semester> semesterOpt = academicDateService.findSemester(date);
@@ -81,9 +81,12 @@ public class ScheduleGeneratorService {
semester.getId(), semester.getId(),
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
); );
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
}
for (ScheduleRule rule : sortRules(rules)) { for (ScheduleRule rule : sortRules(rules)) {
renderRuleForDate(rule, date, group, null, result); processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
} }
} }
@@ -97,7 +100,9 @@ public class ScheduleGeneratorService {
} }
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>(); Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>(); List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date); Optional<Semester> semesterOpt = academicDateService.findSemester(date);
@@ -110,9 +115,12 @@ public class ScheduleGeneratorService {
semester.getId(), semester.getId(),
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
); );
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
}
for (ScheduleRule rule : sortRules(rules)) { for (ScheduleRule rule : sortRules(rules)) {
renderRuleForDate(rule, date, null, teacherId, result); processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
} }
} }
@@ -120,7 +128,8 @@ public class ScheduleGeneratorService {
} }
public void clearCache() { public void clearCache() {
consumedHoursCache.clear(); // Расход часов считается в контексте одного построения расписания.
// Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных.
} }
private void validateRange(LocalDate startDate, LocalDate endDate) { private void validateRange(LocalDate startDate, LocalDate endDate) {
@@ -152,10 +161,34 @@ public class ScheduleGeneratorService {
.toList(); .toList();
} }
private void renderRuleForDate(ScheduleRule rule, private void primeConsumedHoursBeforeRange(Semester semester,
List<ScheduleRule> rules,
LocalDate rangeStart,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress) {
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
if (!semester.getStartDate().isBefore(endExclusive)) {
return;
}
List<ScheduleRule> sortedRules = sortRules(rules);
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
for (ScheduleRule rule : sortedRules) {
processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
}
}
}
private LocalDate minDate(LocalDate first, LocalDate second) {
return first.isBefore(second) ? first : second;
}
private void processRuleForDate(ScheduleRule rule,
LocalDate date, LocalDate date,
StudentGroup targetGroup, StudentGroup targetGroup,
Long targetTeacherId, Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> result) { List<RenderedLessonDto> result) {
if (rule.getLectureAcademicHours() == null if (rule.getLectureAcademicHours() == null
|| rule.getLaboratoryAcademicHours() == null || rule.getLaboratoryAcademicHours() == null
@@ -170,6 +203,9 @@ public class ScheduleGeneratorService {
if (!rule.getSubject().isActiveOn(date)) { if (!rule.getSubject().isActiveOn(date)) {
return; return;
} }
if (!rule.isActiveOn(date)) {
return;
}
Semester semester = semesterOpt.get(); Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
return; return;
} }
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
for (ScheduleRuleSlot slot : slots) { for (ScheduleRuleSlot slot : slots) {
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
continue; continue;
@@ -217,11 +251,8 @@ public class ScheduleGeneratorService {
List<ActiveLessonScope> activeScopes = new ArrayList<>(); List<ActiveLessonScope> activeScopes = new ArrayList<>();
for (LessonScope scope : lessonScopes) { for (LessonScope scope : lessonScopes) {
String consumptionBucket = consumptionBucket(category, scope.subgroupId()); String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId());
int consumedHours = consumedHoursByBucket.computeIfAbsent( int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0);
consumptionBucket,
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
);
if (consumedHours >= totalHours) { if (consumedHours >= totalHours) {
continue; continue;
} }
@@ -233,28 +264,30 @@ public class ScheduleGeneratorService {
continue; continue;
} }
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>(); if (result != null) {
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>(); Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
activeScopes.forEach(scope -> { Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); activeScopes.forEach(scope -> {
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
}); scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values()); });
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
int consumedHours = activeScopes.stream() List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
.mapToInt(ActiveLessonScope::consumedHours) int consumedHours = activeScopes.stream()
.min() .mapToInt(ActiveLessonScope::consumedHours)
.orElse(0); .min()
int remainingAfterLesson = activeScopes.stream() .orElse(0);
.mapToInt(ActiveLessonScope::remainingAfterLesson) int remainingAfterLesson = activeScopes.stream()
.min() .mapToInt(ActiveLessonScope::remainingAfterLesson)
.orElse(0); .min()
.orElse(0);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
totalHours, consumedHours, remainingAfterLesson)); totalHours, consumedHours, remainingAfterLesson));
}
for (ActiveLessonScope scope : activeScopes) { for (ActiveLessonScope scope : activeScopes) {
consumedHoursByBucket.put( consumedHoursProgress.put(
consumptionBucket(category, scope.scope().subgroupId()), consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()),
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
); );
} }
@@ -275,62 +308,20 @@ public class ScheduleGeneratorService {
.toList(); .toList();
} }
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
StudentGroup targetGroup,
LocalDate targetDate,
ScheduleLessonCategory category,
Long subgroupId) {
Long groupId = targetGroup == null ? null : targetGroup.getId();
String cacheKey = rule.getId()
+ ":" + category.name()
+ ":" + (subgroupId == null ? "whole" : subgroupId)
+ ":" + (groupId == null ? "all" : groupId)
+ ":" + targetDate;
return consumedHoursCache.computeIfAbsent(cacheKey,
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
}
private int calculateConsumedHours(ScheduleRule rule,
StudentGroup targetGroup,
LocalDate targetDate,
ScheduleLessonCategory category,
Long subgroupId) {
int consumed = 0;
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
Semester semester = rule.getSemester();
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
continue;
}
int weekNumber = academicDateService.getWeekNumber(semester, date);
if (weekNumber < rule.startWeekFor(category)) {
continue;
}
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
if (eligibleGroups.isEmpty()) {
continue;
}
int dayOfWeek = date.getDayOfWeek().getValue();
ScheduleParity dateParity = academicDateService.getParity(semester, date);
long activeSlots = rule.getSlots().stream()
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
.count();
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
if (consumed >= rule.academicHoursFor(category)) {
return consumed;
}
}
return consumed;
}
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) { private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId); return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
} }
private String consumptionProgressKey(ScheduleRule rule,
StudentGroup targetGroup,
ScheduleLessonCategory category,
Long subgroupId) {
Long groupId = targetGroup == null ? null : targetGroup.getId();
return rule.getId()
+ ":" + consumptionBucket(category, subgroupId)
+ ":" + (groupId == null ? "all" : groupId);
}
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot, private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
ScheduleLessonCategory category, ScheduleLessonCategory category,
List<StudentGroup> eligibleGroups) { List<StudentGroup> eligibleGroups) {
@@ -415,7 +406,8 @@ public class ScheduleGeneratorService {
"Т", "Т",
lessonTypeAcademicHours, lessonTypeAcademicHours,
consumedBeforeLesson, consumedBeforeLesson,
remainingAfterLesson remainingAfterLesson,
slot.getParity()
); );
} }

View File

@@ -1,7 +1,6 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.ScheduleOverride; import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
@@ -9,22 +8,29 @@ import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
public class ScheduleQueryService { public class ScheduleQueryService {
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
private static final long MAX_RANGE_DAYS = 120;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository; private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService;
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository, GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository) { ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository; this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService;
} }
public List<RenderedLessonDto> search(Long groupId, public List<RenderedLessonDto> search(Long groupId,
@@ -37,10 +43,16 @@ public class ScheduleQueryService {
String parity, String parity,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate) {
List<StudentGroup> groups = resolveGroups(groupId, departmentId); validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons = groups.stream() List<RenderedLessonDto> generatedLessons;
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) if (groupId == null && departmentId == null && teacherId != null) {
.toList(); generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream() List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
@@ -124,7 +136,8 @@ public class ScheduleQueryService {
lesson.activityType(), lesson.activityType(),
lesson.lessonTypeAcademicHours(), lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(), lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
lesson.remainingLessonTypeAcademicHoursAfterLesson() lesson.remainingLessonTypeAcademicHoursAfterLesson(),
lesson.ruleParity()
); );
} }
@@ -132,17 +145,39 @@ public class ScheduleQueryService {
return scheduleRuleSlotId + ":" + date; return scheduleRuleSlotId + ":" + date;
} }
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId) { private void validateRange(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("Дата начала и дата окончания обязательны");
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
}
if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
}
}
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
if (groupId != null) { if (groupId != null) {
return groupRepository.findById(groupId) return groupRepository.findById(groupId)
.filter(StudentGroup::isActiveRecord) .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.map(List::of) .map(List::of)
.orElse(List.of()); .orElse(List.of());
} }
List<StudentGroup> groups;
if (departmentId != null) { if (departmentId != null) {
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); groups = groupRepository.findByDepartmentId(departmentId);
} else {
groups = groupRepository.findAll();
} }
return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
}
return groups;
} }
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) { private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
@@ -239,7 +274,8 @@ public class ScheduleQueryService {
source.activityType(), source.activityType(),
source.lessonTypeAcademicHours(), source.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(), source.consumedLessonTypeAcademicHoursBeforeLesson(),
source.remainingLessonTypeAcademicHoursAfterLesson() source.remainingLessonTypeAcademicHoursAfterLesson(),
source.ruleParity()
); );
} }
} }

View File

@@ -0,0 +1,82 @@
package com.magistr.app.service;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.Optional;
@Service
public class StudentGroupLifecycleService {
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) {
this.assignmentRepository = assignmentRepository;
}
public boolean isAvailableForSelection(StudentGroup group, LocalDate date) {
return group != null
&& group.isActiveRecord()
&& group.isActiveOn(date)
&& !isGraduatedOn(group, date);
}
public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) {
if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) {
return false;
}
Optional<Integer> courseLimit = resolveCourseLimit(group);
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) {
return true;
}
}
return false;
}
public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) {
if (group == null || group.isArchivedRecord()) {
return StudentGroupStudyState.ARCHIVED;
}
if (!group.isActiveOn(date)) {
return StudentGroupStudyState.INACTIVE;
}
if (isGraduatedOn(group, date)) {
return StudentGroupStudyState.GRADUATED;
}
if (!hasStarted(group, date)) {
return StudentGroupStudyState.NOT_STARTED;
}
return StudentGroupStudyState.ACTIVE;
}
private boolean hasStarted(StudentGroup group, LocalDate date) {
return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0;
}
private boolean isGraduatedOn(StudentGroup group, LocalDate date) {
return isGraduatedOn(group, date, resolveCourseLimit(group));
}
private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional<Integer> courseLimit) {
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
}
private Optional<Integer> resolveCourseLimit(StudentGroup group) {
if (group.getId() == null) {
return Optional.empty();
}
return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream()
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.filter(calendar -> calendar.getCourseCount() != null)
.map(calendar -> calendar.getCourseCount())
.max(Comparator.naturalOrder());
}
}

View File

@@ -9,22 +9,30 @@ import java.time.LocalDate;
public class CourseAndSemesterCalculator { public class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) { public static int getActualCourse(Integer yearStartStudy) {
LocalDate now = LocalDate.now(); return getActualCourse(yearStartStudy, LocalDate.now());
int currentYear = now.getYear(); }
int currentMonth = now.getMonthValue();
if (currentMonth >= 9) { public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
return currentYear - yearStartStudy + 1; int currentYear = date.getYear();
} else { int currentMonth = date.getMonthValue();
return currentYear - yearStartStudy; int course = currentMonth >= 9
} ? currentYear - yearStartStudy + 1
: currentYear - yearStartStudy;
return Math.max(0, course);
} }
public static int getActualSemester(Integer yearStartStudy) { public static int getActualSemester(Integer yearStartStudy) {
int course = getActualCourse(yearStartStudy); return getActualSemester(yearStartStudy, LocalDate.now());
int currentMonth = LocalDate.now().getMonthValue(); }
if ( currentMonth <= 1 || currentMonth >= 9) { public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
int course = getActualCourse(yearStartStudy, date);
if (course == 0) {
return 0;
}
int currentMonth = date.getMonthValue();
if (currentMonth <= 1 || currentMonth >= 9) {
return course * 2 - 1; return course * 2 - 1;
} else { } else {
return course * 2; return course * 2;
@@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator {
public static int getFutureCourse(Integer yearStartStudy, String periodYears) { public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
int recordYear = Integer.parseInt(periodYears.substring(0, 4)); int recordYear = Integer.parseInt(periodYears.substring(0, 4));
return recordYear - yearStartStudy + 1; return Math.max(0, recordYear - yearStartStudy + 1);
} }
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) { public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
int course = getFutureCourse(yearStartStudy, periodYears); int course = getFutureCourse(yearStartStudy, periodYears);
if (course == 0) {
return 0;
}
if (semesterType == SemesterType.autumn) { if (semesterType == SemesterType.autumn) {
return course * 2 - 1; return course * 2 - 1;

View File

@@ -3,7 +3,7 @@ server.port=8080
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose) # PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
spring.datasource.url=jdbc:postgresql://db:5432/app_db spring.datasource.url=jdbc:postgresql://db:5432/app_db
spring.datasource.username=${POSTGRES_USER:myuser} spring.datasource.username=${POSTGRES_USER:myuser}
spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword} spring.datasource.password=${POSTGRES_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# JPA # JPA
@@ -14,5 +14,11 @@ spring.jpa.open-in-view=false
# Мультитенантность # Мультитенантность
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
#logging.level.root=DEBUG # JWT авторизация
app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum}
app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m}
app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false}
#logging.level.root=DEBUG

View File

@@ -91,6 +91,25 @@ VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Ад
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1) ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING; ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant VARCHAR(100) NOT NULL,
token_hash VARCHAR(64) UNIQUE NOT NULL,
issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
rotated_to_token_hash VARCHAR(64),
user_agent VARCHAR(512),
ip_address VARCHAR(64)
);
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user
ON auth_refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires
ON auth_refresh_tokens(tenant, expires_at);
CREATE TABLE IF NOT EXISTS teacher_department_assignments ( CREATE TABLE IF NOT EXISTS teacher_department_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id), teacher_id BIGINT NOT NULL REFERENCES users(id),
@@ -114,6 +133,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id) ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL; WHERE is_primary = TRUE AND valid_to IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment) INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id' SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u FROM users u
@@ -124,6 +147,34 @@ WHERE u.role = 'TEACHER'
WHERE existing.teacher_id = u.id WHERE existing.teacher_id = u.id
); );
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
username VARCHAR(50) NOT NULL,
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
comment TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
requested_by BIGINT REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_by BIGINT REFERENCES users(id),
reviewed_at TIMESTAMP,
review_comment TEXT,
created_teacher_id BIGINT REFERENCES users(id),
CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_department
ON teacher_creation_requests(department_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_status
ON teacher_creation_requests(status, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_creation_requests_pending_username
ON teacher_creation_requests(username)
WHERE status = 'PENDING';
-- ========================================== -- ==========================================
-- Образовательные формы -- Образовательные формы
-- ========================================== -- ==========================================
@@ -512,7 +563,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
course_count INT NOT NULL DEFAULT 4, course_count INT NOT NULL DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0), CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8),
CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
); );
@@ -566,6 +617,22 @@ CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
ON CONFLICT (calendar_id, course_number, date) DO NOTHING; ON CONFLICT (calendar_id, course_number, date) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
semester_number INT NOT NULL,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0),
CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester
ON academic_calendar_subjects(calendar_id, semester_number);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject
ON academic_calendar_subjects(subject_id);
CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
@@ -913,12 +980,14 @@ COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика';
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания'; COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам'; COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам'; COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар'; COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций'; COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
@@ -950,6 +1019,17 @@ COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
COMMENT ON COLUMN users.department_id IS 'ID кафедры'; COMMENT ON COLUMN users.department_id IS 'ID кафедры';
COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш';
COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен';
COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен';
COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации';
COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена';
COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена';
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
COMMENT ON COLUMN education_forms.description IS 'Описание'; COMMENT ON COLUMN education_forms.description IS 'Описание';
@@ -1038,6 +1118,11 @@ COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
COMMENT ON COLUMN academic_calendar_subjects.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...';
COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects';
COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки';
COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы'; COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы';
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года'; COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика'; COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';

View File

@@ -0,0 +1,6 @@
ALTER TABLE subgroups
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
ON subgroups (group_id, lower(name))
WHERE status <> 'ARCHIVED';

View File

@@ -0,0 +1,104 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
class JwtTokenServiceTest {
@Test
void authenticatesValidTokenForSameTenant() {
JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
String token = service.createAccessToken(user(), "magistr");
var authenticated = service.authenticate(token, "magistr");
assertThat(authenticated).isPresent();
assertThat(authenticated.get().id()).isEqualTo(10L);
assertThat(authenticated.get().username()).isEqualTo("admin");
assertThat(authenticated.get().role()).isEqualTo(Role.ADMIN);
assertThat(authenticated.get().departmentId()).isEqualTo(2L);
}
@Test
void rejectsTokenForDifferentTenant() {
JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
String token = service.createAccessToken(user(), "magistr");
assertThat(service.authenticate(token, "n8n")).isEmpty();
}
@Test
void rejectsTokenWithDifferentSignature() {
JwtTokenService issuer = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
JwtTokenService verifier = new JwtTokenService(properties("abcdefghijklmnopqrstuvwxyz123456", Duration.ofMinutes(15)));
String token = issuer.createAccessToken(user(), "magistr");
assertThat(verifier.authenticate(token, "magistr")).isEmpty();
}
@Test
void rejectsExpiredToken() {
String secret = "12345678901234567890123456789012";
JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15)));
String token = expiredToken(secret);
assertThat(service.authenticate(token, "magistr")).isEmpty();
}
private String expiredToken(String secret) {
try {
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer("magistr")
.subject("10")
.jwtID("expired")
.issueTime(Date.from(now.minusSeconds(120)))
.expirationTime(Date.from(now.minusSeconds(90)))
.claim("tenant", "magistr")
.claim("userId", 10L)
.claim("username", "admin")
.claim("role", "ADMIN")
.claim("departmentId", 2L)
.build();
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8)));
return jwt.serialize();
} catch (JOSEException e) {
throw new IllegalStateException(e);
}
}
private JwtProperties properties(String secret, Duration accessTtl) {
JwtProperties properties = new JwtProperties();
properties.setSecret(secret);
properties.setAccessTtl(accessTtl);
properties.setRefreshTtl(Duration.ofDays(7));
return properties;
}
private User user() {
User user = new User();
user.setId(10L);
user.setUsername("admin");
user.setRole(Role.ADMIN);
user.setDepartmentId(2L);
user.setFullName("Администратор");
user.setJobTitle("Администратор");
user.setPassword("hash");
return user;
}
}

View File

@@ -0,0 +1,115 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RefreshTokenServiceTest {
@Test
void storesOnlyTokenHash() {
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = service.createSession(user(), "magistr", "Browser", "127.0.0.1");
ArgumentCaptor<AuthRefreshToken> captor = ArgumentCaptor.forClass(AuthRefreshToken.class);
verify(repository).save(captor.capture());
AuthRefreshToken stored = captor.getValue();
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
assertThat(stored.getTokenHash()).hasSize(64);
assertThat(stored.getTenant()).isEqualTo("magistr");
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
}
@Test
void rotatesRefreshTokenAndRejectsReplay() {
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = "raw-refresh-token";
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
Optional<RefreshTokenRotation> rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
assertThat(rotation).isPresent();
assertThat(rotation.get().refreshToken()).isNotEqualTo(rawToken);
assertThat(stored.getRevokedAt()).isNotNull();
assertThat(stored.getRotatedToTokenHash()).hasSize(64);
Optional<RefreshTokenRotation> replay = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
assertThat(replay).isEmpty();
verify(repository, times(2)).save(any(AuthRefreshToken.class));
}
@Test
void rejectsTokenFromDifferentTenant() {
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = "raw-refresh-token";
when(repository.findByTokenHash(service.hashToken(rawToken)))
.thenReturn(Optional.of(activeToken(service.hashToken(rawToken))));
assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty();
}
@Test
void logoutRevokesActiveToken() {
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = "raw-refresh-token";
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
service.revoke(rawToken, "magistr");
assertThat(stored.getRevokedAt()).isNotNull();
verify(repository).save(stored);
}
private JwtProperties properties() {
JwtProperties properties = new JwtProperties();
properties.setRefreshTtl(Duration.ofDays(7));
return properties;
}
private AuthRefreshToken activeToken(String hash) {
AuthRefreshToken token = new AuthRefreshToken();
token.setUser(user());
token.setTenant("magistr");
token.setTokenHash(hash);
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
token.setExpiresAt(LocalDateTime.now().plusDays(1));
return token;
}
private User user() {
User user = new User();
user.setId(10L);
user.setUsername("admin");
user.setRole(Role.ADMIN);
user.setDepartmentId(2L);
user.setFullName("Администратор");
user.setJobTitle("Администратор");
user.setPassword("hash");
return user;
}
}

View File

@@ -0,0 +1,23 @@
package com.magistr.app.config.tenant;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TenantConfigWatcherTest {
@Test
void configHashUsesSha256Hex() {
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
assertThat(hash).hasSize(64);
assertThat(hash).matches("[0-9a-f]{64}");
assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
}
@Test
void configHashChangesWhenContentChanges() {
assertThat(TenantConfigWatcher.configHash("tenant-a"))
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
}
}

View File

@@ -0,0 +1,38 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.model.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AuthControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void meAllowsNullDepartmentId() {
AuthController controller = new AuthController(null, null, null, null, null);
AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null));
ResponseEntity<?> response = controller.me();
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("userId", 10L);
assertThat(body).containsEntry("username", "admin");
assertThat(body).containsEntry("role", "ADMIN");
assertThat(body).containsKey("departmentId");
assertThat(body.get("departmentId")).isNull();
}
}

View File

@@ -0,0 +1,141 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DepartmentWorkspaceControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() {
SubjectRepository subjectRepository = mock(SubjectRepository.class);
Subject subject = new Subject();
subject.setId(10L);
subject.setDepartmentId(null);
when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
subjectRepository,
mock(SubjectCommentRepository.class),
mock(UserRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
var response = controller.getComments(10L);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
@Test
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
UserRepository userRepository = mock(UserRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(directTeacher, assignedTeacher));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
List<UserResponse> teachers = controller.getTeachers(1L);
assertThat(teachers).extracting(UserResponse::getFullName)
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
}
@Test
void departmentCanAddExistingTeacherAsSecondaryAssignment() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User teacher = user(20L, "teacher", "Иванов Иван Иванович", 2L);
Department department = new Department(1L, "Кафедра ИБ", 1L);
when(userRepository.findById(20L)).thenReturn(Optional.of(teacher));
when(departmentRepository.findById(1L)).thenReturn(Optional.of(department));
when(assignmentRepository.existsActiveAssignment(20L, 1L, LocalDate.now())).thenReturn(false);
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
departmentRepository,
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
var response = controller.addTeacherToDepartment(20L, Map.of("comment", "Совместительство"));
assertThat(response.getStatusCode().value()).isEqualTo(200);
ArgumentCaptor<TeacherDepartmentAssignment> captor = ArgumentCaptor.forClass(TeacherDepartmentAssignment.class);
verify(assignmentRepository).save(captor.capture());
TeacherDepartmentAssignment saved = captor.getValue();
assertThat(saved.getTeacher()).isEqualTo(teacher);
assertThat(saved.getDepartment()).isEqualTo(department);
assertThat(saved.getPrimaryAssignment()).isFalse();
assertThat(saved.getCreatedBy()).isEqualTo(3L);
}
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
return assignment;
}
private User user(Long id, String username, String fullName, Long departmentId) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setRole(Role.TEACHER);
user.setFullName(fullName);
user.setJobTitle("Доцент");
user.setDepartmentId(departmentId);
return user;
}
}

View File

@@ -0,0 +1,63 @@
package com.magistr.app.controller;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class UserControllerTest {
@Test
void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
assignmentRepository
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
var response = controller.getTeachersByDepartmentId(2L);
assertThat(response.getStatusCode().value()).isEqualTo(200);
@SuppressWarnings("unchecked")
List<UserResponse> body = (List<UserResponse>) response.getBody();
assertThat(body).hasSize(1);
assertThat(body.get(0).getUsername()).isEqualTo("teacher");
assertThat(body.get(0).getDepartmentId()).isEqualTo(2L);
assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ");
assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE);
}
private User user(Long id, String username, Role role, Long departmentId) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setRole(role);
user.setFullName("Тестовый пользователь");
user.setJobTitle("Преподаватель");
user.setDepartmentId(departmentId);
return user;
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.controller;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class WorkloadControllerTest {
@Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
classroomRepository,
mock(UserRepository.class),
mock(DepartmentRepository.class)
);
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
.thenReturn(List.of());
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
assertThat(result).extracting("id").containsExactly(3L);
}
private Classroom classroom(Long id, Boolean available) {
Classroom classroom = new Classroom();
classroom.setId(id);
classroom.setName("Аудитория " + id);
classroom.setCapacity(30);
classroom.setIsAvailable(available);
return classroom;
}
}

View File

@@ -0,0 +1,40 @@
package com.magistr.app.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class LifecycleEntityTest {
@Test
void archivedEntityIsNotActiveOnDateWithoutActiveTo() {
Subject subject = new Subject();
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
}
@Test
void archivedEntityRemainsActiveForHistoricalDatesBeforeActiveTo() {
Subject subject = new Subject();
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
subject.setActiveTo(LocalDate.of(2026, 5, 10));
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 9))).isTrue();
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 11))).isFalse();
}
@Test
void activeEntityUsesDateRange() {
Subject subject = new Subject();
subject.setActiveFrom(LocalDate.of(2026, 1, 10));
subject.setActiveTo(LocalDate.of(2026, 1, 20));
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse();
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue();
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
}
}

View File

@@ -0,0 +1,39 @@
package com.magistr.app.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ScheduleLessonCategoryTest {
@Test
void mapsCanonicalLessonTypes() {
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция")))
.isEqualTo(ScheduleLessonCategory.LECTURE);
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика")))
.isEqualTo(ScheduleLessonCategory.PRACTICE);
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа")))
.isEqualTo(ScheduleLessonCategory.LABORATORY);
}
@Test
void rejectsAmbiguousLessonType() {
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("неоднозначен");
}
@Test
void rejectsUnsupportedLessonType() {
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("должен быть лекцией");
}
private LessonType lessonType(String name) {
LessonType lessonType = new LessonType();
lessonType.setLessonType(name);
return lessonType;
}
}

View File

@@ -0,0 +1,41 @@
package com.magistr.app.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class ScheduleRuleTest {
@Test
void activeOnUsesStatusAndValidityRange() {
ScheduleRule rule = new ScheduleRule();
rule.setValidFrom(LocalDate.of(2026, 2, 1));
rule.setValidTo(LocalDate.of(2026, 2, 10));
assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse();
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue();
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue();
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse();
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse();
}
@Test
void archiveAndRestoreToggleStatus() {
ScheduleRule rule = new ScheduleRule();
rule.archive();
assertThat(rule.isArchivedRecord()).isTrue();
assertThat(rule.isActiveOn(LocalDate.now())).isFalse();
rule.restore();
assertThat(rule.isActiveRecord()).isTrue();
assertThat(rule.getValidTo()).isNull();
}
}

View File

@@ -0,0 +1,77 @@
package com.magistr.app.service;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ScheduleQueryServiceTest {
@Test
void rejectsTooBroadGroupSearch() {
GroupRepository groupRepository = mock(GroupRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
when(groupRepository.findAll())
.thenReturn(IntStream.rangeClosed(1, 51)
.mapToObj(this::group)
.toList());
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
ScheduleQueryService service = new ScheduleQueryService(
mock(ScheduleGeneratorService.class),
groupRepository,
mock(ScheduleOverrideRepository.class),
groupLifecycleService
);
assertThatThrownBy(() -> service.search(null, null, null, null,
null, null, null, null,
LocalDate.of(2026, 5, 1),
LocalDate.of(2026, 5, 7)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Уточните группу или кафедру");
}
@Test
void teacherSearchDoesNotLoadAllGroups() {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService
);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7);
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
service.search(null, 10L, null, null,
null, null, null, null,
startDate,
endDate);
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
verify(groupRepository, never()).findAll();
}
private StudentGroup group(int id) {
StudentGroup group = new StudentGroup();
group.setId((long) id);
group.setName("Группа " + id);
return group;
}
}

View File

@@ -0,0 +1,63 @@
package com.magistr.app.service;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class StudentGroupLifecycleServiceTest {
@Test
void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() {
StudentGroup group = group(1L, 2027);
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
LocalDate date = LocalDate.of(2026, 5, 1);
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED);
assertThat(service.isAvailableForSelection(group, date)).isTrue();
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
}
@Test
void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() {
StudentGroup group = group(1L, 2020);
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
LocalDate date = LocalDate.of(2026, 5, 1);
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED);
assertThat(service.isAvailableForSelection(group, date)).isFalse();
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
}
private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) {
StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class);
when(repository.findByGroupIdWithDetails(group.getId()))
.thenReturn(List.of(assignment(courseCount)));
return new StudentGroupLifecycleService(repository);
}
private StudentGroup group(Long id, int yearStartStudy) {
StudentGroup group = new StudentGroup();
group.setId(id);
group.setName("Группа");
group.setYearStartStudy(yearStartStudy);
return group;
}
private StudentGroupCalendarAssignment assignment(int courseCount) {
AcademicCalendar calendar = new AcademicCalendar();
calendar.setCourseCount(courseCount);
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
assignment.setAcademicCalendar(calendar);
return assignment;
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.utils;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class CourseAndSemesterCalculatorTest {
@Test
void courseAndSemesterAreZeroBeforeStudyStart() {
LocalDate date = LocalDate.of(2026, 5, 1);
assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero();
assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero();
}
@Test
void septemberStartsFirstCourseAndFirstSemester() {
LocalDate date = LocalDate.of(2026, 9, 1);
assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1);
assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1);
}
}

4
backend/tenants.json → backend/tenants.example.json Executable file → Normal file
View File

@@ -4,6 +4,6 @@
"domain": "default", "domain": "default",
"url": "jdbc:postgresql://db:5432/app_db", "url": "jdbc:postgresql://db:5432/app_db",
"username": "myuser", "username": "myuser",
"password": "supersecretpassword" "password": "replace-with-local-password"
} }
] ]

View File

@@ -32,11 +32,11 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_USER: myuser POSTGRES_USER: ${POSTGRES_USER:-myuser}
POSTGRES_PASSWORD: supersecretpassword POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env}
POSTGRES_DB: app_db POSTGRES_DB: ${POSTGRES_DB:-app_db}
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ] test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -19,6 +19,7 @@
- `academic_calendars` хранит график профиля, формы обучения и учебного года. - `academic_calendars` хранит график профиля, формы обучения и учебного года.
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. - Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. - `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
@@ -64,7 +65,8 @@
3. В разделе календарного графика администратор создаёт учебный год и семестры. 3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
6. В разделе групп администратор назначает календарный график группе на нужный учебный год. 6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря. После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря.

View File

@@ -2,6 +2,20 @@
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON. Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
```json
{
"timestamp": "2026-05-27T19:47:54",
"status": 400,
"error": "Некорректный запрос",
"message": "Некорректные параметры запроса",
"path": "/api/schedule"
}
```
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
--- ---
## Аутентификация ## Аутентификация
@@ -23,7 +37,7 @@
{ {
"success": true, "success": true,
"message": "OK", "message": "OK",
"token": "550e8400-e29b-41d4-a716-446655440000", "token": "eyJhbGciOiJIUzI1NiJ9...",
"role": "ADMIN", "role": "ADMIN",
"redirect": "/admin/", "redirect": "/admin/",
"departmentId": 1, "departmentId": 1,
@@ -31,6 +45,8 @@
} }
``` ```
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
**Ошибка (401):** **Ошибка (401):**
```json ```json
{ {
@@ -44,7 +60,7 @@
} }
``` ```
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>` > После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
@@ -59,6 +75,37 @@ Redirect по ролям:
| `TEACHER` | `/teacher/` | | `TEACHER` | `/teacher/` |
| `STUDENT` | `/student/` | | `STUDENT` | `/student/` |
### `POST /api/auth/refresh`
Обновляет access JWT по refresh-cookie. Тело запроса не требуется.
**Успешный ответ (200):**
```json
{
"success": true,
"message": "OK",
"token": "eyJhbGciOiJIUzI1NiJ9...",
"role": "ADMIN",
"redirect": "/admin/",
"departmentId": 1,
"userId": 1
}
```
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
### `POST /api/auth/logout`
Отзывает текущий refresh-токен и очищает refresh-cookie.
**Успешный ответ (200):**
```json
{
"success": true,
"message": "Выход выполнен"
}
```
### `GET /api/auth/me` ### `GET /api/auth/me`
Возвращает текущего пользователя по bearer-токену. Возвращает текущего пользователя по bearer-токену.
@@ -72,6 +119,8 @@ Redirect по ролям:
} }
``` ```
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
--- ---
## Пользователи ## Пользователи
@@ -83,18 +132,22 @@ Redirect по ролям:
**Ответ:** **Ответ:**
```json ```json
[ [
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" }, { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" } { "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
] ]
``` ```
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
### `GET /api/users/teachers` ### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`). Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/{departmentId}` ### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users` ### `POST /api/users`
@@ -114,7 +167,7 @@ Redirect по ролям:
**Валидация:** **Валидация:**
- `username` — обязателен и уникален - `username` — обязателен и уникален
- `password` — минимум 4 символа - `password` — минимум 8 символов
- `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT` - `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен - `fullName` — обязателен
- `departmentId` — обязателен - `departmentId` — обязателен
@@ -145,13 +198,46 @@ Redirect по ролям:
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01` ### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
Список преподавателей кафедры на конкретную дату по таблице истории. Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
---
## Заявки на создание преподавателей
Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
**Тело одобрения:**
```json
{
"departmentId": 2,
"username": "teacher.new",
"password": "secure-pass",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"reviewComment": "Данные проверены"
}
```
Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.
**Тело отклонения:**
```json
{
"reviewComment": "Нужно уточнить ФИО"
}
```
--- ---
## Права ролей на API ## Права ролей на API
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`. Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`.
Важные ограничения: Важные ограничения:
@@ -310,6 +396,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | | `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | | `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
| `PUT` | `/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` | Полная замена привязок дисциплин графика |
**Тело создания/обновления графика:** **Тело создания/обновления графика:**
```json ```json
@@ -324,6 +412,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
``` ```
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`.
**Ячейка сетки графика:** **Ячейка сетки графика:**
```json ```json
@@ -337,6 +426,17 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
} }
``` ```
**Привязка дисциплин к графику:**
```json
[
{ "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` ### `POST /api/admin/schedule-rules`
Создание правила динамического расписания. Создание правила динамического расписания.
@@ -384,6 +484,25 @@ CRUD доступен по:
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
```json
{
"message": "Невозможно сохранить правило: слот занят",
"conflictRule": {
"id": 12,
"subjectName": "Математический анализ",
"semesterId": 1,
"groupNames": ["ИБ-101"],
"slots": []
},
"conflictFields": ["classroom"],
"conflictReasons": ["Аудитория"]
}
```
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
### `GET /api/lesson-types` ### `GET /api/lesson-types`
Справочник типов занятий для конструктора правил расписания. Справочник типов занятий для конструктора правил расписания.
@@ -413,6 +532,8 @@ CRUD доступен по:
| `timeSlotId` | Временной слот | | `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` | | `parity` | `BOTH`, `ODD`, `EVEN` |
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
Пример: Пример:
```http ```http
@@ -486,8 +607,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины | | `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий | | `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры | | `GET` | `/api/department/teachers` | Преподаватели кафедры |
| `POST` | `/api/department/teachers/{teacherId}/assignments` | Добавить существующего преподавателя на кафедру |
| `GET` | `/api/department/teacher-requests` | Заявки кафедры на создание преподавателей |
| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
| `GET` | `/api/department/schedule` | Расписание кафедры | | `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
```json
{
"departmentId": 2,
"comment": "Совместительство"
}
```
`POST /api/department/teacher-requests` принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.
```json
{
"username": "teacher.new",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"comment": "Нужен для дисциплин кафедры"
}
```
--- ---
## Кафедры и специальности ## Кафедры и специальности
@@ -546,7 +692,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `GET /api/groups` ### `GET /api/groups`
Список всех групп. Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
**Ответ:** **Ответ:**
```json ```json
@@ -566,14 +714,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
"specialtyName": "Программная инженерия", "specialtyName": "Программная инженерия",
"specialtyProfileId": 2, "specialtyProfileId": 2,
"specialtyProfileName": "Без профиля", "specialtyProfileName": "Без профиля",
"specialityCode": 1 "specialityCode": 1,
"status": "ACTIVE",
"active": true,
"studyState": "ACTIVE",
"studyStateName": "Активна"
} }
] ]
``` ```
### `GET /api/groups/{departmentId}` ### `GET /api/groups/{departmentId}`
Список всех групп привязанных к конкретной кафедре. Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
### `POST /api/groups` ### `POST /api/groups`
@@ -591,7 +743,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
} }
``` ```
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`. `specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`. Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
@@ -613,12 +767,20 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `DELETE /api/groups/{id}` ### `DELETE /api/groups/{id}`
Удаление группы. Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
### `POST /api/groups/{id}/restore`
Восстановление архивной группы.
### Подгруппы группы ### Подгруппы группы
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп | | `GET` | `/api/subgroups` | Список всех подгрупп |
@@ -662,6 +824,30 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
} }
``` ```
**Ответ назначения:**
```json
{
"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
}
]
}
```
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
--- ---
@@ -906,7 +1092,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| Код | Описание | | Код | Описание |
|-----|----------| |-----|----------|
| `200` | Успех | | `200` | Успех |
| `400` | Ошибка валидации (с `message` в теле) | | `400` | Ошибка валидации или некорректные параметры запроса |
| `401` | Неверные учётные данные | | `401` | Неверные учётные данные |
| `404` | Ресурс / тенант не найден | | `404` | Ресурс / тенант не найден |
| `500` | Внутренняя ошибка сервера | | `500` | Внутренняя ошибка сервера |

View File

@@ -32,7 +32,7 @@ graph TD
- **Порт:** 8080 (внутренний) - **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none` - **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта) - **Миграции:** Flyway (программный запуск при подключении тенанта)
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей - **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
### PostgreSQL ### PostgreSQL
- **Версия:** `postgres:alpine3.23` - **Версия:** `postgres:alpine3.23`
@@ -80,6 +80,7 @@ sequenceDiagram
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | | `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | | `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | | `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API | | `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | | `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
@@ -99,7 +100,7 @@ sequenceDiagram
### Конфигурация тенантов ### Конфигурация тенантов
Список тенантов хранится в JSON-файле: Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json` - **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json` - **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
Формат: Формат:
@@ -121,26 +122,29 @@ sequenceDiagram
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты 2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap 3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
### Fallback при отсутствии тенантов ### Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта: Если при запуске нет ни одного настроенного тенанта:
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` 1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA 2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
--- ---
## Аутентификация ## Аутентификация
Система использует простую модель аутентификации без JWT и без полноценного Spring Security: Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password` 1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
2. Backend проверяет пароль через `BCryptPasswordEncoder` 2. Backend проверяет пароль через `BCryptPasswordEncoder`.
3. При успехе возвращается: 3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
- UUID-токен (для заголовка `Authorization: Bearer`) 4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) 5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) 6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
4. Токен хранится в `localStorage` на клиенте
Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно. Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution. `TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.

View File

@@ -8,7 +8,7 @@
|------|------|------------| |------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
@@ -27,7 +27,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`; - загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение; - общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя. - привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
--- ---
@@ -57,9 +59,11 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Учебные группы (Student Groups) ### Учебные группы (Student Groups)
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) - **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms) ### Аудитории (Classrooms)
@@ -90,14 +94,21 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия. Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
### Перевод преподавателей между кафедрами Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`. ### Кафедральные связи преподавателей
Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные.
Правила: Правила:
- у преподавателя должна быть одна открытая основная кафедра; - у преподавателя должна быть одна открытая основная кафедра;
- преподаватель может иметь несколько открытых неосновных кафедр;
- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`; - при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой. - расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
--- ---
@@ -115,6 +126,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | | `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | | `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `academic_calendar_days` | Дневная сетка по курсу и дате | | `academic_calendar_days` | Дневная сетка по курсу и дате |
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта | | `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
@@ -136,6 +148,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. 11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
@@ -162,12 +180,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа. - **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. - **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. - **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
### Точечные изменения расписания ### Точечные изменения расписания

View File

@@ -48,6 +48,17 @@ erDiagram
TIMESTAMP created_at TIMESTAMP created_at
TIMESTAMP updated_at TIMESTAMP updated_at
} }
auth_refresh_tokens {
BIGSERIAL id PK
BIGINT user_id FK
VARCHAR tenant
VARCHAR token_hash UK
TIMESTAMP issued_at
TIMESTAMP expires_at
TIMESTAMP revoked_at
VARCHAR rotated_to_token_hash
}
education_forms { education_forms {
BIGSERIAL id PK BIGSERIAL id PK
@@ -137,6 +148,21 @@ erDiagram
TEXT comment TEXT comment
} }
teacher_creation_requests {
BIGSERIAL id PK
BIGINT department_id FK
VARCHAR username
VARCHAR full_name
VARCHAR job_title
TEXT comment
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TIMESTAMP reviewed_at
TEXT review_comment
BIGINT created_teacher_id FK
}
subject_comments { subject_comments {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT subject_id FK BIGINT subject_id FK
@@ -222,6 +248,14 @@ erDiagram
BIGINT activity_type_id FK BIGINT activity_type_id FK
} }
academic_calendar_subjects {
BIGSERIAL id PK
BIGINT calendar_id FK
INT semester_number
BIGINT subject_id FK
TIMESTAMP created_at
}
student_group_calendar_assignments { student_group_calendar_assignments {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT group_id FK BIGINT group_id FK
@@ -292,13 +326,17 @@ erDiagram
student_groups ||--o{ schedule_rule_groups : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id"
student_groups ||--o{ student_group_calendar_assignments : "group_id" student_groups ||--o{ student_group_calendar_assignments : "group_id"
users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_subjects : "user_id"
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id" users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id" departments ||--o{ teacher_department_assignments : "department_id"
departments ||--o{ teacher_creation_requests : "department_id"
users ||--o{ teacher_creation_requests : "requested_by/reviewed_by/created_teacher_id"
users ||--o{ teacher_lesson_types : "user_id" users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id" users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id" subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ academic_calendar_subjects : "subject_id"
subjects ||--o{ schedule_rules : "subject_id" subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
@@ -313,6 +351,7 @@ erDiagram
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_id" education_forms ||--o{ academic_calendars : "study_form_id"
academic_calendars ||--o{ academic_calendar_days : "calendar_id" academic_calendars ||--o{ academic_calendar_days : "calendar_id"
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
@@ -379,6 +418,22 @@ erDiagram
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. > **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
#### `auth_refresh_tokens` — Refresh-сессии JWT
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID refresh-сессии |
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
| `issued_at` | TIMESTAMP | Дата выдачи |
| `expires_at` | TIMESTAMP | Дата истечения |
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
| `user_agent` | VARCHAR(512) | User-Agent клиента |
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
### Учебный процесс ### Учебный процесс
#### `education_forms` — Формы обучения #### `education_forms` — Формы обучения
@@ -401,6 +456,11 @@ erDiagram
| `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | | `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
| `active_from` | DATE | Дата начала действия группы |
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
| `archived_at` | TIMESTAMP | Дата и время архивирования |
| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы #### `subgroups` — Подгруппы
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -410,7 +470,7 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы | | `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов | | `student_capacity` | INT | Количество студентов |
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины #### `subjects` — Дисциплины
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -495,7 +555,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи | | `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод | | `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр.
#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID заявки |
| `department_id` | BIGINT FK → departments | Кафедра, которая запрашивает преподавателя |
| `username` | VARCHAR(50) | Предлагаемый логин |
| `full_name` | VARCHAR(255) | ФИО преподавателя |
| `job_title` | VARCHAR(255) | Должность |
| `comment` | TEXT | Комментарий кафедры |
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
| `review_comment` | TEXT | Комментарий администратора |
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
| `created_at` | TIMESTAMP | Дата создания заявки |
| `updated_at` | TIMESTAMP | Дата последнего изменения |
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
#### `subject_comments` — Комментарии к дисциплинам #### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -575,7 +655,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | | `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | | `course_count` | INT CHECK(18) | Количество курсов в сетке |
| `created_at` | TIMESTAMP | Дата создания | | `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления | | `updated_at` | TIMESTAMP | Дата обновления |
@@ -590,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели ISO | | `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
#### `academic_calendar_subjects` — Дисциплины календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
| `created_at` | TIMESTAMP | Дата создания привязки |
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`.
#### `student_group_calendar_assignments` — Назначения графиков группам #### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -616,6 +707,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `version_group_id` | BIGINT | Группа версий одного правила | | `version_group_id` | BIGINT | Группа версий одного правила |
| `change_reason` | TEXT | Причина изменения | | `change_reason` | TEXT | Причина изменения |
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
#### `schedule_rule_groups` — Группы правила #### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -674,17 +767,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
1. Все миграции находятся в `backend/src/main/resources/db/migration/` 1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) 2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway 3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. > Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции ### Текущие миграции
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | | `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

@@ -222,11 +222,13 @@ public class AbsenceController {
### Правила ### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций 1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... 3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения 4. После добавления — перезапустите backend для применения
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
### Применение ### Применение
```bash ```bash
@@ -252,11 +254,13 @@ com.magistr.app/
│ ├── TenantContext.java # ThreadLocal текущего тенанта │ ├── TenantContext.java # ThreadLocal текущего тенанта
│ ├── TenantInterceptor.java # Определение тенанта из Host │ ├── TenantInterceptor.java # Определение тенанта из Host
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД │ ├── TenantRoutingDataSource.java # Маршрутизация к БД
│ ├── TenantDataSourceConfig.java # Spring-конфигурация │ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
│ ├── TenantConfigWatcher.java # Периодическая синхронизация │ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap │ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
├── controller/ # REST-контроллеры ├── controller/ # REST-контроллеры
│ ├── AuthController.java │ ├── AuthController.java
│ ├── GlobalExceptionHandler.java
│ ├── ScheduleController.java │ ├── ScheduleController.java
│ ├── ClassroomController.java │ ├── ClassroomController.java
│ ├── DatabaseController.java │ ├── DatabaseController.java

View File

@@ -37,35 +37,31 @@ frontend/
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── users.js # Управление пользователями │ │ ├── dashboard.js # Дашборд
│ │ ├── groups.js # Управление группами │ │ ├── users.js # Управление пользователями
│ │ ├── classrooms.js # Управление аудиториями │ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
│ │ ├── subjects.js # Управление дисциплинами │ │ ├── groups.js # Управление группами
│ │ ├── equipments.js # Управление оборудованием │ │ ├── classrooms.js # Управление аудиториями
│ │ ├── edu-forms.js # Формы обучения │ │ ├── subjects.js # Управление дисциплинами
│ │ ├── profiles.js # Профили обучения специальностей │ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели │ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах │ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений │ ├── views/ # HTML-шаблоны представлений
│ │ ├── dashboard.html
│ │ ├── users.html │ │ ├── users.html
│ │ ├── teacher-requests.html
│ │ ├── groups.html │ │ ├── groups.html
│ │ ├── classrooms.html │ │ ├── classrooms.html
│ │ ├── subjects.html │ │ ├── subjects.html
│ │ ├── equipments.html │ │ ├── university-structure.html
│ │ ├── edu-forms.html
│ │ ├── profiles.html
│ │ ├── department-workspace.html │ │ ├── department-workspace.html
│ │ ├── schedule-view.html │ │ ├── schedule-view.html
│ │ ├── schedule.html │ │ ├── schedule.html
│ │ ├── academic-calendar.html │ │ ├── academic-calendar.html
│ │ ── auditorium-workload.html │ │ ── auditorium-workload.html
│ │ ├── database.html
│ │ └── departments-data.html
│ │ │ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA) │ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar │ ├── index.html # Оболочка с собственной sidebar
@@ -129,31 +125,32 @@ frontend/
| Tab | Описание | API | | Tab | Описание | API |
|-----|----------|-----| |-----|----------|-----|
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` | | `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` | | `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -171,19 +168,24 @@ frontend/
## API-клиент (`api.js`) ## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`: Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
```javascript ```javascript
export async function apiFetch(endpoint, method = 'GET', body = null) { export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method, method,
headers: { headers: getHeaders(body ? 'application/json' : null),
'Authorization': `Bearer ${token}`, credentials: 'same-origin',
'Content-Type': 'application/json' body: body ? JSON.stringify(body) : undefined
},
body: body ? JSON.stringify(body) : null
}); });
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) return apiFetch(endpoint, method, body, false);
clearAuthState();
window.location.href = '/';
}
if (!response.ok) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
} }
@@ -200,7 +202,9 @@ export const api = {
}; };
``` ```
Токен берётся из `localStorage.getItem('token')`. При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
--- ---
@@ -211,11 +215,12 @@ export const api = {
1. Пользователь вводит логин/пароль 1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login` 2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`: 3. При успехе сохраняет в `localStorage`:
- `token`UUID-токен - `token`access JWT
- `role` — роль пользователя - `role` — роль пользователя
- `departmentId` — кафедра пользователя - `departmentId` — кафедра пользователя
- `userId` — ID пользователя для личного расписания преподавателя - `userId` — ID пользователя для личного расписания преподавателя
4. Перенаправляет на соответствующий интерфейс: 4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
5. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/` - `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view` - `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace` - `DEPARTMENT``/admin/#department-workspace`
@@ -230,13 +235,13 @@ export const api = {
```javascript ```javascript
export function isAuthenticatedAsAdmin() { export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === 'ADMIN'; return getToken() && role === 'ADMIN';
} }
``` ```
### Выход ### Выход
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
--- ---
@@ -310,16 +315,27 @@ CSS-переменные позволяют поддерживать светл
:root { :root {
--bg-primary: #ffffff; --bg-primary: #ffffff;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
--accent: #6366f1; --accent: #4F46E5;
} }
[data-theme="dark"] { [data-theme="dark"] {
--bg-primary: #0f0f23; --bg-primary: #0f0f23;
--text-primary: #e2e8f0; --text-primary: #e2e8f0;
--accent: #818cf8; --accent: #8B5CF6;
} }
``` ```
### Система кнопок
Кнопки админ-панели, отдельной SPA настроек, страницы входа и личных кабинетов используют единую семантическую модель:
- `btn` — обязательная база для всех кнопок и ссылок-кнопок.
- Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий.
- Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке.
- Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления.
- Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`.
- Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.
Переключение — через `theme-toggle.js`. Переключение — через `theme-toggle.js`.
--- ---

View File

@@ -25,13 +25,21 @@ docker network create proxy
```env ```env
POSTGRES_USER=myuser POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword POSTGRES_PASSWORD=replace-with-local-password
POSTGRES_DB=app_db
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
``` ```
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
### Dockerfile (Backend) ### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven: Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine``mvn package` 1. Этап сборки: `maven:3.9-eclipse-temurin-17``mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar` 2. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
### Dockerfile (Frontend) ### Dockerfile (Frontend)
@@ -58,6 +66,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
| `frontend` | Deployment | Apache httpd | | `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов | | `tenants-config` | ConfigMap | JSON-список тенантов |
### JWT настройки
`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
- `JWT_ACCESS_TOKEN_TTL=15m`
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
### ConfigMap для тенантов ### ConfigMap для тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.

View File

@@ -576,12 +576,31 @@
gap: 1rem; gap: 1rem;
} }
.schedule-view-control-card {
position: relative;
z-index: 10;
}
.schedule-view-results-card {
position: relative;
z-index: 5;
}
.schedule-view-table-card {
position: relative;
z-index: 1;
}
.schedule-view-control-card, .schedule-view-control-card,
.schedule-view-results-card, .schedule-view-results-card,
.schedule-view-table-card { .schedule-view-table-card {
animation-duration: 0.28s; animation-duration: 0.28s;
} }
.schedule-view-results-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schedule-view-control-header, .schedule-view-control-header,
.schedule-view-results-header, .schedule-view-results-header,
.schedule-view-table-header { .schedule-view-table-header {
@@ -589,6 +608,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.schedule-view-control-header h2, .schedule-view-control-header h2,
@@ -634,32 +654,6 @@
gap: 0.45rem; gap: 0.45rem;
} }
.schedule-view-shell .btn-secondary {
min-height: 42px;
padding: 0.65rem 0.9rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent-hover);
transform: translateY(-1px);
}
.schedule-view-shell .btn-secondary:disabled {
cursor: default;
opacity: 0.45;
}
.schedule-view-extra-filters { .schedule-view-extra-filters {
margin-top: 0.9rem; margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -684,10 +678,11 @@
.schedule-view-result-tabs { .schedule-view-result-tabs {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.55rem; gap: 0.55rem;
margin-top: 1rem; margin-top: 0;
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
overflow-x: auto; overflow-x: visible;
} }
.schedule-view-result-tab { .schedule-view-result-tab {
@@ -923,8 +918,8 @@
width: 100%; width: 100%;
} }
.schedule-view-period-buttons .btn-secondary, .schedule-view-period-buttons .btn,
.schedule-view-result-actions .btn-secondary { .schedule-view-result-actions .btn {
flex: 1; flex: 1;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -25,15 +25,7 @@
} }
.sidebar-close-btn { .sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition); transition: all var(--transition);
} }
@@ -112,6 +104,7 @@
} }
.nav-item svg { .nav-item svg {
flex: 0 0 auto;
transition: transform var(--transition); transition: transform var(--transition);
} }
@@ -120,6 +113,38 @@
transform: scale(1.15) rotate(-5deg); transform: scale(1.15) rotate(-5deg);
} }
.nav-item span:not(.nav-badge) {
min-width: 0;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
margin-left: auto;
padding: 0 0.45rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 999px;
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
}
.nav-badge[hidden],
.topbar-request-badge[hidden] {
display: none !important;
}
[data-theme="light"] .nav-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.sidebar-footer { .sidebar-footer {
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -255,7 +280,7 @@
display: none; display: none;
} }
.sidebar.collapsed .nav-item span, .sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span { .sidebar.collapsed .btn-settings span {
position: absolute; position: absolute;
left: calc(100% + 10px); left: calc(100% + 10px);
@@ -279,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }
[data-theme="light"] .sidebar.collapsed .nav-item span, [data-theme="light"] .sidebar.collapsed .nav-item span:not(.nav-badge),
[data-theme="light"] .sidebar.collapsed .btn-settings span { [data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.sidebar.collapsed .nav-item:hover span, .sidebar.collapsed .nav-item:hover span:not(.nav-badge),
.sidebar.collapsed .btn-settings:hover span { .sidebar.collapsed .btn-settings:hover span {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@@ -306,6 +331,16 @@
padding: 0.75rem 0; padding: 0.75rem 0;
overflow: visible; overflow: visible;
} }
.sidebar.collapsed .nav-badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
min-width: 18px;
height: 18px;
padding: 0 0.3rem;
font-size: 0.65rem;
}
.sidebar.collapsed .btn-settings { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
@@ -341,9 +376,44 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
}
.topbar-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1; flex: 1;
} }
.topbar-request-badge {
min-height: 30px;
padding: 0 0.75rem;
border: 1px solid rgba(248, 113, 113, 0.26);
border-radius: 999px;
background: rgba(248, 113, 113, 0.13);
color: #fecaca;
font: inherit;
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.topbar-request-badge:hover {
transform: translateY(-1px);
background: rgba(248, 113, 113, 0.2);
border-color: rgba(248, 113, 113, 0.42);
}
[data-theme="light"] .topbar-request-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.content { .content {
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
display: flex; display: flex;
@@ -355,12 +425,7 @@
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
.menu-toggle { .menu-toggle {
display: none; display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }

View File

@@ -51,9 +51,9 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--error: #ef4444; --error: #ef4444;

View File

@@ -73,3 +73,82 @@
.modal-close:hover { .modal-close:hover {
color: var(--error); color: var(--error);
} }
#modal-edit-group .edit-group-modal {
width: min(980px, calc(100vw - 2rem));
max-height: none;
overflow: visible;
}
#modal-edit-group #edit-group-form {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
#modal-edit-group .edit-group-form-row {
display: grid;
gap: 1rem;
align-items: end;
}
#modal-edit-group .edit-group-form-row-main {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#modal-edit-group .form-group {
min-width: 0;
}
#modal-edit-group .edit-group-submit {
display: flex;
align-items: flex-end;
}
#modal-edit-group .edit-group-submit .btn {
width: 100%;
}
@media (max-width: 980px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: 1fr;
}
}
#schedule-conflict-modal {
padding: 0.35rem;
overflow-x: hidden;
overflow-y: auto;
}
#schedule-conflict-modal .modal-content {
width: min(1760px, calc(100vw - 0.7rem));
max-width: calc(100vw - 0.7rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}
#schedule-move-slot-modal .modal-content {
width: min(620px, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}

View File

@@ -16,6 +16,19 @@
<link rel="stylesheet" href="css/modals.css"> <link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css"> <link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css"> <link rel="stylesheet" href="css/auditorium-workload.css">
<style>
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}
</style>
</head> </head>
<body> <body>
@@ -36,79 +49,35 @@
</svg> </svg>
<span>Magistr</span> <span>Magistr</span>
</div> </div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель"> <button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="users"> <!-- Главная -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <a href="#" class="nav-item" data-tab="dashboard">
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a>
<a href="#" class="nav-item" data-tab="departments-data">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path> <rect x="3" y="3" width="7" height="9" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect> <rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg> </svg>
<span>Создание кафедры/специальности</span> <span>Дашборд</span>
</a> </a>
<a href="#" class="nav-item" data-tab="profiles">
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<circle cx="12" cy="7" r="4"></circle> <rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 11.5 18 14l3-5"></path> <path d="M8 11h8" />
<path d="M8 16h5" />
</svg> </svg>
<span>Профили обучения</span> <span>Заявки</span>
</a> <span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы</span>
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="equipments">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
<span>Оборудование</span>
</a>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудитории</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a> </a>
<a href="#" class="nav-item" data-tab="department-workspace"> <a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -120,14 +89,11 @@
</svg> </svg>
<span>Кабинет кафедры</span> <span>Кабинет кафедры</span>
</a> </a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <!-- Раздел: Расписание -->
stroke-linecap="round" stroke-linejoin="round"> <div class="sidebar-section-divider"></div>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /> <div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="schedule"> <a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -135,7 +101,7 @@
<line x1="8" y1="2" x2="8" y2="6"></line> <line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line> <line x1="3" y1="10" x2="21" y2="10"></line>
</svg> </svg>
<span>Расписание занятий</span> <span>Конструктор правил</span>
</a> </a>
<a href="#" class="nav-item" data-tab="academic-calendar"> <a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -155,13 +121,63 @@
</svg> </svg>
<span>Загруженность</span> <span>Загруженность</span>
</a> </a>
<a href="#" class="nav-item" data-tab="database"> <a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse> stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path> <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path> <circle cx="12" cy="12" r="3" />
</svg> </svg>
<span>База данных</span> <span>Просмотр расписаний</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудиторный фонд</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="university-structure">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
<span>Структура вуза</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a>
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -188,7 +204,7 @@
Общие настройки Общие настройки
</a> </a>
<div class="settings-menu-divider"></div> <div class="settings-menu-divider"></div>
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout"> <button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -208,7 +224,7 @@
<!-- Main --> <!-- Main -->
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="menu-toggle" id="menu-toggle" aria-label="Меню"> <button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />
@@ -216,7 +232,10 @@
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<h1 id="page-title">Загрузка...</h1> <div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header> </header>
<!-- Dynamic Content Injected Here --> <!-- Dynamic Content Injected Here -->

View File

@@ -1,38 +1,43 @@
const token = localStorage.getItem('token'); let refreshPromise = null;
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
export function getToken() { export function getToken() {
return token; return localStorage.getItem('token');
} }
export function isAuthenticatedAsAdmin() { export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === 'ADMIN'; return getToken() && role === 'ADMIN';
} }
export function isAuthenticatedAsRole(expectedRole) { export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === expectedRole; return getToken() && role === expectedRole;
} }
export function isAuthenticatedAsAny(roles) { export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && roles.includes(role); return getToken() && roles.includes(role);
} }
function getHeaders(contentType = 'application/json') { function getHeaders(contentType = 'application/json') {
const headers = { const headers = {};
'Authorization': `Bearer ${token}` const token = getToken();
}; if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (contentType) { if (contentType) {
headers['Content-Type'] = contentType; headers['Content-Type'] = contentType;
} }
return headers; return headers;
} }
export async function apiFetch(endpoint, method = 'GET', body = null) { export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const options = { const options = {
method, method,
headers: getHeaders(body ? 'application/json' : null) headers: getHeaders(body ? 'application/json' : null),
credentials: 'same-origin'
}; };
if (body) { if (body) {
@@ -48,16 +53,141 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
data = null; data = null;
} }
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiFetch(endpoint, method, body, false);
}
clearAuthState();
window.location.href = '/';
}
if (!response.ok) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); const error = new Error(data?.message || `Ошибка HTTP: ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
} }
return data; return data;
} }
export async function refreshAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin'
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.token) {
return false;
}
storeAuthState(data);
return true;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
const cache = new Map();
const CACHABLE_PREFIXES = [
'/api/departments',
'/api/specialties',
'/api/education-forms',
'/api/users/teachers',
'/api/equipments',
'/api/classrooms'
];
function invalidateCache(url) {
const cacheKeysToClear = [
'/api/departments',
'/api/specialties',
'/api/education-forms',
'/api/users',
'/api/teacher-requests',
'/api/department/teacher-requests',
'/api/department/teachers',
'/api/equipments',
'/api/classrooms'
];
const affectedPrefixes = new Set(cacheKeysToClear.filter(prefix => url.includes(prefix)));
if (url.includes('/api/teacher-requests')) {
affectedPrefixes.add('/api/users');
}
if (url.includes('/api/department/teachers')) {
affectedPrefixes.add('/api/users/teachers');
}
for (const prefix of affectedPrefixes) {
for (const cacheKey of cache.keys()) {
if (cacheKey.includes(prefix)) {
cache.delete(cacheKey);
}
}
}
}
export function clearCache() {
cache.clear();
}
export async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
console.warn('Не удалось завершить серверную сессию:', e.message);
} finally {
clearAuthState();
clearCache();
}
}
export function storeAuthState(data) {
if (data.token) localStorage.setItem('token', data.token);
if (data.role) localStorage.setItem('role', data.role);
if (data.departmentId !== undefined && data.departmentId !== null) {
localStorage.setItem('departmentId', data.departmentId);
}
if (data.userId !== undefined && data.userId !== null) {
localStorage.setItem('userId', data.userId);
}
}
export function clearAuthState() {
AUTH_KEYS.forEach(key => localStorage.removeItem(key));
}
export const api = { export const api = {
get: (url) => apiFetch(url, 'GET'), get: async (url) => {
post: (url, body) => apiFetch(url, 'POST', body), const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
put: (url, body) => apiFetch(url, 'PUT', body), if (isCachable) {
delete: (url, body = null) => apiFetch(url, 'DELETE', body) if (!cache.has(url)) {
cache.set(url, await apiFetch(url, 'GET'));
}
return cache.get(url);
}
return apiFetch(url, 'GET');
},
post: async (url, body) => {
invalidateCache(url);
return apiFetch(url, 'POST', body);
},
put: async (url, body) => {
invalidateCache(url);
return apiFetch(url, 'PUT', body);
},
delete: async (url, body = null) => {
invalidateCache(url);
return apiFetch(url, 'DELETE', body);
}
}; };

View File

@@ -99,6 +99,35 @@ export class CustomSelect {
this.menu.innerHTML = ''; this.menu.innerHTML = '';
const options = Array.from(this.originalSelect.options); const options = Array.from(this.originalSelect.options);
// Show search input if more than 6 options exist
if (options.length > 6) {
const searchLi = document.createElement('li');
searchLi.className = 'custom-select-search-container';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Поиск...';
searchInput.className = 'custom-select-search-input';
searchInput.addEventListener('click', (e) => e.stopPropagation());
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (item.classList.contains('placeholder-item')) return;
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
});
searchLi.appendChild(searchInput);
this.menu.appendChild(searchLi);
this.searchInput = searchInput;
} else {
this.searchInput = null;
}
if (options.length === 0) { if (options.length === 0) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'custom-select-item disabled'; li.className = 'custom-select-item disabled';
@@ -171,7 +200,22 @@ export class CustomSelect {
if (!isOpen) { if (!isOpen) {
this.layerParent?.classList.add('select-layer-active'); this.layerParent?.classList.add('select-layer-active');
this.originalSelect.closest('.form-group, .form-field')?.classList.add('select-layer-active');
this.wrapper.classList.add('open'); this.wrapper.classList.add('open');
if (this.searchInput) {
this.searchInput.value = '';
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (!item.classList.contains('placeholder-item')) {
item.style.display = '';
}
});
setTimeout(() => {
this.searchInput.focus();
}, 50);
}
// Scroll selected item into view // Scroll selected item into view
const selectedItem = this.menu.querySelector('.selected'); const selectedItem = this.menu.querySelector('.selected');
if (selectedItem) { if (selectedItem) {
@@ -194,6 +238,7 @@ export class CustomSelect {
close() { close() {
this.wrapper.classList.remove('open'); this.wrapper.classList.remove('open');
this.layerParent?.classList.remove('select-layer-active'); this.layerParent?.classList.remove('select-layer-active');
this.originalSelect.closest('.form-group, .form-field')?.classList.remove('select-layer-active');
} }
handleItemClick(e, index) { handleItemClick(e, index) {

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
} }
import { isAuthenticatedAsAny } from './api.js'; import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -20,47 +20,114 @@ document.addEventListener('DOMContentLoaded', () => {
startDropdownAutoObserver(); startDropdownAutoObserver();
}); });
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
import { initEduForms } from './views/edu-forms.js';
import { initEquipments } from './views/equipments.js';
import { initClassrooms } from './views/classrooms.js';
import { initSubjects } from './views/subjects.js';
import {initSchedule} from "./views/schedule.js";
import {initDatabase} from "./views/database.js";
import {initDepartmentsData} from "./views/departments-data.js";
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
import {initProfiles} from "./views/profiles.js";
import {initAcademicCalendar} from "./views/academic-calendar.js";
import {initScheduleView} from "./views/schedule-view.js";
import {initDepartmentWorkspace} from "./views/department-workspace.js";
// Configuration // Configuration
const ROUTES = { const ROUTES = {
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers }, dashboard: {
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups }, title: 'Дашборд',
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms }, file: '/admin/views/dashboard.html',
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments }, init: async () => {
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms }, const module = await import('./views/dashboard.js');
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects }, await module.initDashboard();
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace }, }
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView }, },
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule }, users: {
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar }, title: 'Управление пользователями',
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload }, file: '/admin/views/users.html',
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase }, init: async () => {
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData }, const module = await import('./views/users.js');
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles }, await module.initUsers();
}
},
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
init: async () => {
const module = await import('./views/groups.js');
await module.initGroups();
}
},
classrooms: {
title: 'Аудиторный фонд',
file: '/admin/views/classrooms.html',
init: async () => {
const module = await import('./views/classrooms.js');
await module.initClassrooms();
}
},
subjects: {
title: 'Дисциплины',
file: '/admin/views/subjects.html',
init: async () => {
const module = await import('./views/subjects.js');
await module.initSubjects();
}
},
'university-structure': {
title: 'Структура вуза',
file: '/admin/views/university-structure.html',
init: async () => {
const module = await import('./views/university-structure.js');
await module.initUniversityStructure();
}
},
'department-workspace': {
title: 'Кабинет кафедры',
file: '/admin/views/department-workspace.html',
init: async () => {
const module = await import('./views/department-workspace.js');
await module.initDepartmentWorkspace();
}
},
'schedule-view': {
title: 'Просмотр расписаний',
file: '/admin/views/schedule-view.html',
init: async () => {
const module = await import('./views/schedule-view.js');
await module.initScheduleView();
}
},
schedule: {
title: 'Расписание занятий',
file: '/admin/views/schedule.html',
init: async () => {
const module = await import('./views/schedule.js');
await module.initSchedule();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',
init: async () => {
const module = await import('./views/academic-calendar.js');
await module.initAcademicCalendar();
}
},
'auditorium-workload': {
title: 'Загруженность',
file: '/admin/views/auditorium-workload.html',
init: async () => {
const module = await import('./views/auditorium-workload.js');
await module.initAuditoriumWorkload();
}
},
}; };
const ROLE_NAVIGATION = { const ROLE_NAVIGATION = {
ADMIN: { ADMIN: {
defaultTab: 'users', defaultTab: 'dashboard',
tabs: Object.keys(ROUTES) tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
}, },
EDUCATION_OFFICE: { EDUCATION_OFFICE: {
defaultTab: 'schedule-view', defaultTab: 'dashboard',
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments'] tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
}, },
DEPARTMENT: { DEPARTMENT: {
defaultTab: 'department-workspace', defaultTab: 'department-workspace',
@@ -84,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER; const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs); const allowedTabs = new Set(roleNavigation.tabs);
@@ -91,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
configureRoleInterface(); configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -143,12 +213,15 @@ document.addEventListener('click', (e) => {
}); });
// Logout // Logout
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', async () => {
localStorage.removeItem('token'); await logout();
localStorage.removeItem('role');
window.location.href = '/'; window.location.href = '/';
}); });
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation // Navigation
navItems.forEach(item => { navItems.forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
@@ -177,10 +250,13 @@ async function switchTab(tab) {
// Initialize logic for the tab // Initialize logic for the tab
if (ROUTES[tab].init) { if (ROUTES[tab].init) {
ROUTES[tab].init(); await ROUTES[tab].init();
} }
currentTab = tab; currentTab = tab;
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
} catch (e) { } catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`; appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e); console.error(e);
@@ -210,5 +286,48 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle; document.querySelector('.logo span').textContent = firstAllowedTitle;
} }
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', ''); const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
switchTab(hashTab);
}
});

View File

@@ -27,7 +27,7 @@ export function hideAlert(elementId) {
export function applyRippleEffect() { export function applyRippleEffect() {
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .btn-delete, .btn-logout'); const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
if (!btn) return; if (!btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();

View File

@@ -70,6 +70,16 @@ export async function initAcademicCalendar() {
const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear');
const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]'));
const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]'));
const subjectCalendarSelect = document.getElementById('calendar-subject-calendar');
const subjectSemesterSelect = document.getElementById('calendar-subject-semester');
const subjectSelect = document.getElementById('calendar-subject-id');
const subjectAddButton = document.getElementById('calendar-subject-add');
const subjectSaveButton = document.getElementById('calendar-subject-save');
const subjectRefreshButton = document.getElementById('calendar-subject-refresh');
const subjectSummary = document.getElementById('calendar-subject-summary');
const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = []; let academicYears = [];
let semesters = []; let semesters = [];
@@ -78,10 +88,18 @@ export async function initAcademicCalendar() {
let studyForms = []; let studyForms = [];
let activityTypes = []; let activityTypes = [];
let calendars = []; let calendars = [];
let subjects = [];
let calendarSubjectRows = [];
let timeSlotScopes = []; let timeSlotScopes = [];
let timeSlotDateAssignments = []; let timeSlotDateAssignments = [];
let activeCalendarDay = null; let activeCalendarDay = null;
let selectedCalendarDayKey = null; let selectedCalendarDayKey = null;
let selectedCalendarDays = [];
let isCalendarDragSelecting = false;
let didCalendarDragSelect = false;
let suppressCalendarDayClick = false;
let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null;
bindEvents(); bindEvents();
@@ -89,6 +107,7 @@ export async function initAcademicCalendar() {
await Promise.all([ await Promise.all([
loadBaseLists(), loadBaseLists(),
loadYears(), loadYears(),
loadSubjects(),
loadStudyForms(), loadStudyForms(),
loadActivityTypes(), loadActivityTypes(),
loadTimeSlotScopes(), loadTimeSlotScopes(),
@@ -100,6 +119,9 @@ export async function initAcademicCalendar() {
} }
function bindEvents() { function bindEvents() {
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
});
academicYearsRefreshButton?.addEventListener('click', loadYears); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
@@ -109,6 +131,11 @@ export async function initAcademicCalendar() {
editorLoadButton?.addEventListener('click', loadCalendarGrid); editorLoadButton?.addEventListener('click', loadCalendarGrid);
editorSaveButton?.addEventListener('click', saveCalendarGrid); editorSaveButton?.addEventListener('click', saveCalendarGrid);
fillApplyButton?.addEventListener('click', applyCalendarFill); fillApplyButton?.addEventListener('click', applyCalendarFill);
subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects);
subjectAddButton?.addEventListener('click', addCalendarSubject);
subjectSaveButton?.addEventListener('click', saveCalendarSubjects);
subjectRefreshButton?.addEventListener('click', loadCalendarSubjects);
subjectSummary?.addEventListener('click', handleSubjectSummaryClick);
calendarGrid?.addEventListener('change', (event) => { calendarGrid?.addEventListener('change', (event) => {
if (event.target.matches('.calendar-day-activity')) { if (event.target.matches('.calendar-day-activity')) {
@@ -116,10 +143,34 @@ export async function initAcademicCalendar() {
renderCalendarTotals(); renderCalendarTotals();
} }
}); });
calendarGrid?.addEventListener('mousedown', (event) => {
const day = event.target.closest('.calendar-day');
if (day) startCalendarDayDragSelection(event, day);
});
calendarGrid?.addEventListener('mouseover', (event) => {
const day = event.target.closest('.calendar-day');
if (day) addCalendarDayToDragSelection(day);
});
calendarGrid?.addEventListener('mousemove', (event) => {
const day = event.target.closest('.calendar-day');
if (day) showCalendarDayTooltip(day, event);
});
calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip);
calendarGrid?.addEventListener('focusin', (event) => {
const day = event.target.closest('.calendar-day');
if (day) showCalendarDayTooltip(day);
});
calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip);
calendarGrid?.addEventListener('click', (event) => { calendarGrid?.addEventListener('click', (event) => {
if (suppressCalendarDayClick) {
suppressCalendarDayClick = false;
event.preventDefault();
return;
}
const day = event.target.closest('.calendar-day'); const day = event.target.closest('.calendar-day');
if (day) openCalendarDayDialog(day); if (day) openCalendarDayDialog(day);
}); });
document.addEventListener('mouseup', finishCalendarDayDragSelection);
calendarGrid?.addEventListener('keydown', (event) => { calendarGrid?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return; if (event.key !== 'Enter' && event.key !== ' ') return;
const day = event.target.closest('.calendar-day'); const day = event.target.closest('.calendar-day');
@@ -132,14 +183,17 @@ export async function initAcademicCalendar() {
if (event.target === calendarDayDialog) closeCalendarDayDialog(); if (event.target === calendarDayDialog) closeCalendarDayDialog();
}); });
calendarDayDialogActivitySelect?.addEventListener('change', () => { calendarDayDialogActivitySelect?.addEventListener('change', () => {
if (!activeCalendarDay) return; if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return;
const select = activeCalendarDay.querySelector('.calendar-day-activity'); const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []);
select.value = calendarDayDialogActivitySelect.value; days.forEach(day => {
updateCalendarDayVisual(activeCalendarDay); const select = day.querySelector('.calendar-day-activity');
updateCalendarDayDialogSummary(activeCalendarDay); select.value = calendarDayDialogActivitySelect.value;
updateCalendarDayVisual(day);
});
updateCalendarDayDialogSummaryForSelection(days);
renderCalendarTotals(); renderCalendarTotals();
}); });
calendarDayDialogTimeSaveButton?.addEventListener('click', saveTimeSlotDateAssignment); calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear); academicYearForm?.addEventListener('submit', saveAcademicYear);
@@ -184,12 +238,14 @@ export async function initAcademicCalendar() {
} }
academicYearsTbody.innerHTML = academicYears.map(year => ` academicYearsTbody.innerHTML = academicYears.map(year => `
<tr> <tr>
<td>${escapeHtml(year.title)}</td> <td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td> <td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
<td>${renderSemesterList(year.semesters || [])}</td> <td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
<td> <td class="academic-year-actions-cell">
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button> <div class="academic-year-actions">
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button> <button class="btn btn-sm btn-secondary btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-year" data-id="${year.id}">Удалить</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -197,12 +253,19 @@ export async function initAcademicCalendar() {
function renderSemesterList(items) { function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>'; if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => ` return `<div class="academic-year-semesters">${items.map(semester => {
<div class="schedule-semester-line"> const title = semesterTypeTitle(semester);
${escapeHtml(semesterLabel(semester))} const period = formatDisplayDateRange(semester.startDate, semester.endDate);
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button> return `
</div> <div class="academic-year-semester-item">
`).join(''); <div class="academic-year-semester-meta">
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
</div>
<button class="btn btn-sm btn-secondary btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
</div>
`;
}).join('')}</div>`;
} }
async function saveAcademicYear(event) { async function saveAcademicYear(event) {
@@ -249,7 +312,7 @@ export async function initAcademicCalendar() {
if (editSemesterButton) { if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return; if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра'; semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
semesterIdInput.value = semester.id; semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || ''; semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
@@ -319,6 +382,11 @@ export async function initAcademicCalendar() {
populateStudyFormSelect(); populateStudyFormSelect();
} }
async function loadSubjects() {
subjects = await api.get('/api/subjects');
populateSubjectSelect();
}
async function loadActivityTypes() { async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types'); activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects(); populateActivitySelects();
@@ -338,6 +406,7 @@ export async function initAcademicCalendar() {
calendars = await api.get('/api/admin/academic-calendars'); calendars = await api.get('/api/admin/academic-calendars');
renderCalendars(); renderCalendars();
populateEditorCalendarSelect(); populateEditorCalendarSelect();
populateSubjectCalendarSelect();
} catch (error) { } catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
@@ -358,9 +427,10 @@ export async function initAcademicCalendar() {
<td>${escapeHtml(calendar.studyFormName || '-')}</td> <td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td> <td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button> <button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button> <button class="btn btn-sm btn-secondary btn-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -398,9 +468,11 @@ export async function initAcademicCalendar() {
async function handleCalendarTableClick(event) { async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar'); const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid'); const gridButton = event.target.closest('.btn-open-calendar-grid');
const subjectsButton = event.target.closest('.btn-open-calendar-subjects');
const deleteButton = event.target.closest('.btn-delete-calendar'); const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) { if (editButton) {
activateCalendarTab('manage');
const calendar = calendars.find(item => item.id == editButton.dataset.id); const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return; if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика'; calendarFormTitle.textContent = 'Редактирование календарного графика';
@@ -418,13 +490,23 @@ export async function initAcademicCalendar() {
} }
if (gridButton) { if (gridButton) {
activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id; editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect); refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid(); await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
if (subjectsButton) {
activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id;
refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить календарный график?')) return; if (!confirm('Удалить календарный график?')) return;
try { try {
@@ -447,6 +529,149 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert'); hideAlert('academic-calendar-alert');
} }
function activateCalendarTab(tabName) {
const target = tabName || 'manage';
calendarTabButtons.forEach(button => {
const active = button.dataset.calendarTab === target;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
calendarTabPanels.forEach(panel => {
const active = panel.dataset.calendarTabPanel === target;
panel.classList.toggle('active', active);
panel.hidden = !active;
});
}
async function loadCalendarSubjects() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
populateSubjectSemesterSelect();
calendarSubjectRows = [];
if (!calendar) {
renderCalendarSubjects();
return;
}
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
renderCalendarSubjects();
} catch (error) {
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
}
}
function addCalendarSubject() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
const semesterNumber = Number(subjectSemesterSelect.value);
const subjectId = Number(subjectSelect.value);
if (!calendar || !semesterNumber || !subjectId) {
showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error');
return;
}
if (calendarSubjectRows.some(row =>
Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId
)) {
showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error');
return;
}
const subject = subjects.find(item => Number(item.id) === subjectId);
if (!subject) {
showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error');
return;
}
calendarSubjectRows = sortCalendarSubjectRows([
...calendarSubjectRows,
{
id: null,
calendarId: calendar.id,
semesterNumber,
subjectId,
subjectName: subject.name,
subjectCode: subject.code,
departmentId: subject.departmentId
}
]);
renderCalendarSubjects();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
}
async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (!calendar) {
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
return;
}
const payload = calendarSubjectRows.map(row => ({
semesterNumber: Number(row.semesterNumber),
subjectId: Number(row.subjectId)
}));
try {
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
renderCalendarSubjects();
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
} catch (error) {
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
}
}
function handleSubjectSummaryClick(event) {
const removeButton = event.target.closest('.calendar-subject-remove');
if (!removeButton) return;
const semesterNumber = Number(removeButton.dataset.semester);
const subjectId = Number(removeButton.dataset.subject);
calendarSubjectRows = calendarSubjectRows.filter(row =>
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
);
renderCalendarSubjects();
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
}
function renderCalendarSubjects() {
const calendar = selectedSubjectCalendar();
if (!calendar) {
subjectCount.textContent = '';
subjectSummary.innerHTML = '<div class="loading-row">Выберите календарный график</div>';
return;
}
const semesterCount = calendarSemesterCount(calendar);
subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`;
subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
const semesterNumber = index + 1;
const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber);
return `
<section class="calendar-subject-semester">
<div class="calendar-subject-semester-head">
<strong>${escapeHtml(semesterOptionLabel(semesterNumber))}</strong>
<span>${semesterRows.length} дисциплин</span>
</div>
${semesterRows.length
? `<div class="calendar-subject-chips">${semesterRows.map(renderSubjectChip).join('')}</div>`
: '<div class="calendar-subject-empty">Нет дисциплин</div>'}
</section>
`;
}).join('');
}
function renderSubjectChip(row) {
return `
<span class="calendar-subject-chip">
<span>${escapeHtml(subjectRowLabel(row))}</span>
<button type="button"
class="calendar-subject-remove"
data-semester="${escapeHtml(String(row.semesterNumber))}"
data-subject="${escapeHtml(String(row.subjectId))}"
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
</span>
`;
}
async function loadCalendarGrid() { async function loadCalendarGrid() {
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
@@ -524,9 +749,10 @@ export async function initAcademicCalendar() {
function renderCalendarGrid(rows) { function renderCalendarGrid(rows) {
activeCalendarDay = null; activeCalendarDay = null;
selectedCalendarDayKey = null; selectedCalendarDayKey = null;
selectedCalendarDays = [];
const grouped = groupBy(rows, row => row.courseNumber); const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<details class="calendar-course-block" ${index === 0 ? 'open' : ''}> <details class="calendar-course-block">
<summary class="calendar-course-header"> <summary class="calendar-course-header">
<span class="calendar-course-title"> <span class="calendar-course-title">
<span class="calendar-course-chevron" aria-hidden="true"></span> <span class="calendar-course-chevron" aria-hidden="true"></span>
@@ -546,39 +772,56 @@ export async function initAcademicCalendar() {
function renderCourseSemesters(course, courseRows) { function renderCourseSemesters(course, courseRows) {
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
const maxWeekColumns = Math.max(
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
1
);
return semestersForCourse.map(block => ` return semestersForCourse.map(block => `
<section class="calendar-semester-block"> <section class="calendar-semester-block">
<div class="calendar-semester-header"> <div class="calendar-semester-header">
<h4>${block.semesterNumber} семестр</h4> <h4>${block.semesterNumber} семестр</h4>
<span>${escapeHtml(block.caption)}</span> <span>${escapeHtml(block.caption)}</span>
</div> </div>
${renderSemesterTable(block.rows)} ${renderSemesterTable(block.rows, maxWeekColumns)}
</section> </section>
`).join(''); `).join('');
} }
function renderSemesterTable(rows) { function renderSemesterTable(rows, targetWeekColumns = 0) {
if (!rows.length) { if (!rows.length) {
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>'; return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
} }
const rowsByWeek = groupBy(rows, row => row.weekNumber); const rowsByWeek = groupBy(rows, row => row.weekNumber);
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
const weekSlots = [...weeks];
while (weekSlots.length < targetWeekColumns) {
weekSlots.push(null);
}
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return ` return `
<div class="calendar-semester-table-wrap"> <div class="calendar-semester-table-wrap">
<table class="calendar-semester-table"> <table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
</colgroup>
<thead> <thead>
<tr> <tr>
<th class="calendar-day-head">День</th> <th class="calendar-day-head">День</th>
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')} ${weekSlots.map(week => week === null
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${DAY_OPTIONS.map(day => ` ${DAY_OPTIONS.map(day => `
<tr> <tr>
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th> <th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
${weeks.map(week => { ${weekSlots.map(week => {
if (week === null) {
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`); const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>'; return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
}).join('')} }).join('')}
@@ -590,6 +833,10 @@ export async function initAcademicCalendar() {
`; `;
} }
function uniqueWeekNumbers(rows) {
return Array.from(new Set(rows.map(row => row.weekNumber)));
}
function renderCalendarDay(row) { function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = shortDayLabel(row.dayOfWeek); const dayName = shortDayLabel(row.dayOfWeek);
@@ -607,7 +854,6 @@ export async function initAcademicCalendar() {
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}" aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}"
title="${escapeHtml(`${formatLongDate(row.date)}, ${dayName}, ${row.weekNumber} неделя, ${code} - ${activity.name || ''}`)}"
style="--activity-color:${escapeHtml(color)}"> style="--activity-color:${escapeHtml(color)}">
<strong class="calendar-day-code">${escapeHtml(code)}</strong> <strong class="calendar-day-code">${escapeHtml(code)}</strong>
${timeAssignmentForDate(row.date) ? '<span class="calendar-day-time-marker">вр.</span>' : ''} ${timeAssignmentForDate(row.date) ? '<span class="calendar-day-time-marker">вр.</span>' : ''}
@@ -617,20 +863,48 @@ export async function initAcademicCalendar() {
} }
function openCalendarDayDialog(day) { function openCalendarDayDialog(day) {
activeCalendarDay = day; openCalendarDayDialogForSelection([day]);
selectCalendarDay(day); }
calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`;
calendarDayDialogCourse.textContent = `${day.dataset.course} курс`; function openCalendarDayDialogForSelection(days) {
calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`; const selectedDays = uniqueCalendarDays(days);
calendarDayDialogDay.textContent = dayLabel(day.dataset.day); if (!selectedDays.length) return;
activeCalendarDay = selectedDays[0];
selectCalendarDays(selectedDays);
const dates = selectedDays.map(day => day.dataset.date).sort();
const courses = new Set(selectedDays.map(day => day.dataset.course));
const weeks = new Set(selectedDays.map(day => day.dataset.week));
const weekDays = new Set(selectedDays.map(day => day.dataset.day));
const isSingle = selectedDays.length === 1;
calendarDayDialogDate.textContent = isSingle
? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})`
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
calendarDayDialogCourse.textContent = courses.size === 1
? `${activeCalendarDay.dataset.course} курс`
: `${courses.size} курса`;
calendarDayDialogWeek.textContent = weeks.size === 1
? `${activeCalendarDay.dataset.week} неделя`
: `${weeks.size} недель`;
calendarDayDialogDay.textContent = weekDays.size === 1
? dayLabel(activeCalendarDay.dataset.day)
: `${selectedDays.length} ячеек`;
calendarDayDialogActivitySelect.innerHTML = options( calendarDayDialogActivitySelect.innerHTML = options(
activityTypes.map(toActivityOption), activityTypes.map(toActivityOption),
day.querySelector('.calendar-day-activity').value commonCalendarDayActivityValue(selectedDays)
); );
populateTimeScopeDialogSelect(day.dataset.date); if (isSingle) {
populateTimeScopeDialogSelect(activeCalendarDay.dataset.date);
} else {
populateMultiSelectionTimeScopeDialogSelect();
}
isSyncingCalendarDayDialog = true;
syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogActivitySelect);
syncSelects(calendarDayDialogTimeScopeSelect); syncSelects(calendarDayDialogTimeScopeSelect);
updateCalendarDayDialogSummary(day); isSyncingCalendarDayDialog = false;
updateCalendarDayDialogSummaryForSelection(selectedDays);
renderCalendarTotals(); renderCalendarTotals();
calendarDayDialog.classList.add('open'); calendarDayDialog.classList.add('open');
} }
@@ -655,6 +929,33 @@ export async function initAcademicCalendar() {
`; `;
} }
function updateCalendarDayDialogSummaryForSelection(days) {
if (days.length <= 1) {
updateCalendarDayDialogSummary(days[0]);
return;
}
const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) {
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:#64748b">...</span>
<div>
<strong>Выбрано ячеек: ${days.length}</strong>
<p>Выберите код активности, чтобы применить его ко всему выделению</p>
</div>
`;
return;
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)} · ${days.length} ячеек</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
</div>
`;
}
async function saveTimeSlotDateAssignment() { async function saveTimeSlotDateAssignment() {
if (!activeCalendarDay) return; if (!activeCalendarDay) return;
hideAlert('calendar-day-dialog-time-alert'); hideAlert('calendar-day-dialog-time-alert');
@@ -707,10 +1008,19 @@ export async function initAcademicCalendar() {
calendarDayDialogTimeScopeSelect.innerHTML = `<option value="">${escapeHtml(automaticLabel)}</option>` + calendarDayDialogTimeScopeSelect.innerHTML = `<option value="">${escapeHtml(automaticLabel)}</option>` +
manualScopes.map(scope => `<option value="${scope.id}">${escapeHtml(scope.name)}</option>`).join(''); manualScopes.map(scope => `<option value="${scope.id}">${escapeHtml(scope.name)}</option>`).join('');
calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || '';
calendarDayDialogTimeSaveButton.disabled = manualScopes.length === 0 && !assignment; calendarDayDialogTimeScopeSelect.disabled = false;
calendarDayDialogTimeSaveButton.disabled = false;
calendarDayDialogTimeClearButton.disabled = !assignment; calendarDayDialogTimeClearButton.disabled = !assignment;
} }
function populateMultiSelectionTimeScopeDialogSelect() {
calendarDayDialogTimeScopeSelect.innerHTML = '<option value="">Для выделения доступен только код дня</option>';
calendarDayDialogTimeScopeSelect.value = '';
calendarDayDialogTimeScopeSelect.disabled = true;
calendarDayDialogTimeSaveButton.disabled = false;
calendarDayDialogTimeClearButton.disabled = true;
}
function updateTimeAssignmentVisuals(date) { function updateTimeAssignmentVisuals(date) {
const assignment = timeAssignmentForDate(date); const assignment = timeAssignmentForDate(date);
calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => {
@@ -735,14 +1045,133 @@ export async function initAcademicCalendar() {
day.style.setProperty('--activity-color', color); day.style.setProperty('--activity-color', color);
day.querySelector('.calendar-day-code').textContent = code; day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); }
function showCalendarDayTooltip(day, event = null) {
if (isCalendarDragSelecting) {
hideCalendarDayTooltip();
return;
}
const tooltip = ensureCalendarDayTooltip();
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b');
tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
calendarDayTooltip?.classList.remove('visible');
}
function ensureCalendarDayTooltip() {
if (calendarDayTooltip) return calendarDayTooltip;
calendarDayTooltip = document.createElement('div');
calendarDayTooltip.className = 'calendar-day-tooltip';
calendarDayTooltip.setAttribute('role', 'tooltip');
document.body.appendChild(calendarDayTooltip);
return calendarDayTooltip;
}
function renderCalendarDayTooltip(day) {
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-';
const color = activity?.colorCode || '#64748b';
const timeAssignment = timeAssignmentForDate(day.dataset.date);
const timeLabel = timeAssignment
? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
return `
<div class="calendar-day-tooltip-head">
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
<div>
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
</div>
</div>
<div class="calendar-day-tooltip-body">
<div>
<span>Курс</span>
<strong>${escapeHtml(day.dataset.course)}</strong>
</div>
<div>
<span>Активность</span>
<strong>${escapeHtml(activity?.name || 'Не задана')}</strong>
</div>
<div>
<span>Время</span>
<strong>${escapeHtml(timeLabel)}</strong>
</div>
</div>
`;
}
function positionCalendarDayTooltip(tooltip, day, event) {
const offset = 14;
const rect = day.getBoundingClientRect();
const x = event?.clientX ?? rect.left + rect.width / 2;
const y = event?.clientY ?? rect.top;
const tooltipRect = tooltip.getBoundingClientRect();
let left = x + offset;
let top = y + offset;
if (left + tooltipRect.width > window.innerWidth - 12) {
left = x - tooltipRect.width - offset;
}
if (top + tooltipRect.height > window.innerHeight - 12) {
top = y - tooltipRect.height - offset;
}
tooltip.style.left = `${Math.max(12, left)}px`;
tooltip.style.top = `${Math.max(12, top)}px`;
}
function startCalendarDayDragSelection(event, day) {
if (event.button !== 0) return;
hideCalendarDayTooltip();
isCalendarDragSelecting = true;
didCalendarDragSelect = false;
suppressCalendarDayClick = false;
selectCalendarDays([day]);
event.preventDefault();
}
function addCalendarDayToDragSelection(day) {
if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return;
didCalendarDragSelect = true;
selectedCalendarDays.push(day);
day.classList.add('is-selected');
renderCalendarTotals();
}
function finishCalendarDayDragSelection() {
if (!isCalendarDragSelecting) return;
isCalendarDragSelecting = false;
if (didCalendarDragSelect && selectedCalendarDays.length > 1) {
suppressCalendarDayClick = true;
openCalendarDayDialogForSelection(selectedCalendarDays);
}
} }
function selectCalendarDay(day) { function selectCalendarDay(day) {
if (!day) return; selectCalendarDays(day ? [day] : []);
selectedCalendarDayKey = calendarDayKey(day); }
function selectCalendarDays(days) {
const uniqueDays = uniqueCalendarDays(days);
selectedCalendarDays = uniqueDays;
selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null;
calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected'));
day.classList.add('is-selected'); uniqueDays.forEach(item => item.classList.add('is-selected'));
renderCalendarTotals();
}
function uniqueCalendarDays(days) {
return Array.from(new Set(days.filter(Boolean)));
}
function commonCalendarDayActivityValue(days) {
const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || '');
const uniqueValues = Array.from(new Set(values));
return uniqueValues.length === 1 ? uniqueValues[0] : '';
} }
function calendarDayKey(day) { function calendarDayKey(day) {
@@ -807,10 +1236,12 @@ export async function initAcademicCalendar() {
const key = activity ? activity.code : '-'; const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1); counts.set(key, (counts.get(key) || 0) + 1);
}); });
const selectedDay = selectedCalendarDayKey const selectedDays = selectedCalendarDays.length
? Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey) ? selectedCalendarDays
: null; : (selectedCalendarDayKey
const selectedActivity = selectedDay ? activityById(selectedDay.querySelector('.calendar-day-activity').value) : null; ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean)
: []);
const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null;
const selectedCode = selectedActivity?.code || null; const selectedCode = selectedActivity?.code || null;
calendarTotals.innerHTML = Array.from(counts.entries()) calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru')) .sort(([a], [b]) => a.localeCompare(b, 'ru'))
@@ -856,6 +1287,21 @@ export async function initAcademicCalendar() {
syncSelects(calendarStudyFormSelect); syncSelects(calendarStudyFormSelect);
} }
function populateSubjectSelect() {
if (!subjectSelect) return;
const current = subjectSelect.value;
const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
subjectSelect.innerHTML = sortedSubjects.length
? '<option value="">Выберите дисциплину</option>' + sortedSubjects.map(subject =>
`<option value="${subject.id}">${escapeHtml(subjectLabel(subject))}</option>`
).join('')
: '<option value="">Нет дисциплин</option>';
if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) {
subjectSelect.value = current;
}
syncSelects(subjectSelect);
}
function populateActivitySelects() { function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' + const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join(''); activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
@@ -873,6 +1319,41 @@ export async function initAcademicCalendar() {
syncSelects(editorCalendarSelect); syncSelects(editorCalendarSelect);
} }
function populateSubjectCalendarSelect() {
if (!subjectCalendarSelect) return;
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
subjectCalendarSelect.value = current;
}
populateSubjectSemesterSelect();
syncSelects(subjectCalendarSelect);
}
function populateSubjectSemesterSelect() {
if (!subjectSemesterSelect) return;
const calendar = selectedSubjectCalendar();
const current = subjectSemesterSelect.value;
if (!calendar) {
subjectSemesterSelect.innerHTML = '<option value="">Выберите график</option>';
syncSelects(subjectSemesterSelect);
return;
}
const semesterCount = calendarSemesterCount(calendar);
subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
const semesterNumber = index + 1;
return `<option value="${semesterNumber}">${escapeHtml(semesterOptionLabel(semesterNumber))}</option>`;
}).join('');
if (current && Number(current) >= 1 && Number(current) <= semesterCount) {
subjectSemesterSelect.value = current;
}
syncSelects(subjectSemesterSelect);
}
function populateFillCourseSelect() { function populateFillCourseSelect() {
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' + fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
@@ -897,6 +1378,38 @@ export async function initAcademicCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
} }
function selectedSubjectCalendar() {
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
}
function calendarSemesterCount(calendar) {
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
}
function semesterOptionLabel(semesterNumber) {
const course = Math.ceil(Number(semesterNumber) / 2);
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
return `${semesterNumber} семестр · ${course} курс, ${type}`;
}
function sortCalendarSubjectRows(rows) {
return [...rows].sort((a, b) => {
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
if (semesterDiff !== 0) return semesterDiff;
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
});
}
function subjectLabel(subject) {
const code = subject.code ? `${subject.code} · ` : '';
return `${code}${subject.name || 'Без названия'}`;
}
function subjectRowLabel(row) {
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
}
function theoryActivityId() { function theoryActivityId() {
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
} }
@@ -925,7 +1438,13 @@ export async function initAcademicCalendar() {
function semesterLabel(semester) { function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType); const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
}
function semesterTypeTitle(semester) {
const type = normalizeSemesterType(semester.semesterType);
const label = SEMESTER_LABELS[type] || type || 'семестр';
return label.charAt(0).toUpperCase() + label.slice(1);
} }
function normalizeSemesterType(type) { function normalizeSemesterType(type) {
@@ -956,6 +1475,16 @@ export async function initAcademicCalendar() {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
function formatDisplayDate(value) {
const [year, month, day] = String(value || '').split('-');
if (!year || !month || !day) return String(value || '');
return `${day}-${month}-${year}`;
}
function formatDisplayDateRange(startDate, endDate) {
return `${formatDisplayDate(startDate)}${formatDisplayDate(endDate)}`;
}
function dateRangeCaption(rows) { function dateRangeCaption(rows) {
if (!rows.length) return ''; if (!rows.length) return '';
const dates = rows.map(row => row.date).sort(); const dates = rows.map(row => row.date).sort();
@@ -1001,7 +1530,16 @@ export async function initAcademicCalendar() {
function syncSelects(...selects) { function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
refreshSelectVisuals(select);
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
}); });
} }
function refreshSelectVisuals(...selects) {
selects.filter(Boolean).forEach(select => {
if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu();
}
});
}
} }

View File

@@ -68,13 +68,6 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'EVEN', label: 'Чётная' } { parity: 'EVEN', label: 'Чётная' }
]; ];
const CAPACITY_BUCKETS = [
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
];
export async function initAuditoriumWorkload() { export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date'); const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh'); const refreshButton = document.getElementById('workload-refresh');
@@ -89,6 +82,7 @@ export async function initAuditoriumWorkload() {
const roomTables = document.getElementById('room-workload-tables'); const roomTables = document.getElementById('room-workload-tables');
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter')); const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
const minCapacityInput = document.getElementById('workload-min-capacity');
let classrooms = []; let classrooms = [];
let teachers = []; let teachers = [];
@@ -117,7 +111,6 @@ export async function initAuditoriumWorkload() {
function initFilters() { function initFilters() {
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
} }
@@ -142,7 +135,6 @@ export async function initAuditoriumWorkload() {
[ [
['building-checkboxes', 'building-text', 'Все корпуса'], ['building-checkboxes', 'building-text', 'Все корпуса'],
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
['equipment-checkboxes', 'equipment-text', 'Любое оборудование'] ['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
].forEach(([containerId, textId, emptyText]) => { ].forEach(([containerId, textId, emptyText]) => {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -153,6 +145,12 @@ export async function initAuditoriumWorkload() {
} }
}); });
}); });
minCapacityInput?.addEventListener('input', () => {
if (currentView === DISPLAY_ALL) {
renderGrid();
}
});
} }
async function loadInitialData() { async function loadInitialData() {
@@ -345,12 +343,6 @@ export async function initAuditoriumWorkload() {
item => item.value, item => item.value,
item => item.label item => item.label
); );
renderCheckboxes(
'capacity-checkboxes',
CAPACITY_BUCKETS,
item => item.id,
item => item.name
);
renderCheckboxes( renderCheckboxes(
'equipment-checkboxes', 'equipment-checkboxes',
equipmentOptions(), equipmentOptions(),
@@ -359,7 +351,6 @@ export async function initAuditoriumWorkload() {
); );
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса'); updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
} }
@@ -934,14 +925,12 @@ export async function initAuditoriumWorkload() {
function filteredClassrooms() { function filteredClassrooms() {
const selectedBuildings = selectedValues('building-checkboxes'); const selectedBuildings = selectedValues('building-checkboxes');
const selectedCapacities = selectedValues('capacity-checkboxes'); const minCapacity = parseInt(minCapacityInput?.value || '0', 10) || 0;
const selectedEquipment = selectedValues('equipment-checkboxes'); const selectedEquipment = selectedValues('equipment-checkboxes');
return classrooms.filter(room => { return classrooms.filter(room => {
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room)); const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
.filter(bucket => selectedCapacities.includes(bucket.id))
.some(bucket => bucket.match(room.capacity));
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id => const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
(room.equipments || []).some(item => String(item.id) === String(id)) (room.equipments || []).some(item => String(item.id) === String(id))
); );

View File

@@ -1,6 +1,35 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js'; import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
updateSelectText(containerId, selectTextId);
return;
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span>
</label>
`).join('');
updateSelectText(containerId, selectTextId);
}
export async function initClassrooms() { export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody'); const classroomsTbody = document.getElementById('classrooms-tbody');
@@ -59,16 +88,16 @@ export async function initClassrooms() {
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}"> <span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span> </span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус"> <button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button> </button>
</div> </div>
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button> <button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED' ${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>` ? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -97,9 +126,9 @@ export async function initClassrooms() {
}); });
classroomsTbody.addEventListener('click', async (e) => { classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete'); const btnDelete = e.target.closest('.btn-archive-classroom');
const btnToggleStatus = e.target.closest('.btn-icon-toggle'); const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.btn-edit-classroom'); const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) { if (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
@@ -191,5 +220,82 @@ export async function initClassrooms() {
} }
}); });
// Элементы модалки оборудования
const btnManageEquipments = document.getElementById('btn-manage-equipments');
const modalManageEquipments = document.getElementById('modal-manage-equipments');
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
const manageEquipmentForm = document.getElementById('manage-equipment-form');
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
modalManageEquipments.addEventListener('click', (e) => {
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
});
async function openManageEquipmentsModal() {
hideAlert('manage-equipment-alert');
manageEquipmentForm.reset();
modalManageEquipments.classList.add('open');
await loadManageEquipments();
}
async function loadManageEquipments() {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
allEquipments = await fetchEquipments();
renderManageEquipmentsTable();
// Обновим чекбоксы на основной странице
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
} catch (e) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderManageEquipmentsTable() {
if (!allEquipments || !allEquipments.length) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr>
`).join('');
}
manageEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-equipment-alert');
const name = manageEquipmentNameInput.value.trim();
if (!name) {
showAlert('manage-equipment-alert', 'Введите название', 'error');
return;
}
try {
const data = await api.post('/api/equipments', { name });
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
manageEquipmentForm.reset();
await loadManageEquipments();
} catch (err) {
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
}
});
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
alert(err.message || 'Ошибка удаления');
}
});
loadInitialData(); loadInitialData();
} }

View File

@@ -0,0 +1,322 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDashboard() {
// DOM Элементы
const metricGroups = document.getElementById('metric-groups');
const metricStudents = document.getElementById('metric-students');
const metricTeachers = document.getElementById('metric-teachers');
const metricClassrooms = document.getElementById('metric-classrooms');
const currentTimeSlot = document.getElementById('current-time-slot');
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
const freeClassroomsList = document.getElementById('free-classrooms-list');
const conflictsContainer = document.getElementById('conflicts-container');
const btnRecheck = document.getElementById('btn-recheck-conflicts');
let departments = [];
let classrooms = [];
let groups = [];
let teachers = [];
let todayLessons = [];
let timeSlots = [];
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.textContent = '...';
await checkConflicts();
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
});
async function loadData() {
try {
// Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([
api.get('/api/departments'),
api.get('/api/classrooms?includeArchived=false'),
api.get('/api/groups?includeArchived=false'),
api.get('/api/users/teachers')
]);
// Выводим метрики
metricGroups.textContent = groups.length;
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = new Date().toISOString().split('T')[0];
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
);
const lessonsArrays = await Promise.all(lessonsPromises);
todayLessons = lessonsArrays.flat();
try {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
} catch (e) {
// Если эндпоинт effective не поддерживается, используем базовые слоты
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
}
// Сортируем слоты по времени
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
// Посчитаем занятость аудиторий сегодня
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
const classPercent = activeClassrooms.length
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
: 0;
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
await checkConflicts();
} catch (e) {
console.error("Dashboard data load error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
}
}
function updateRealtimeMonitoring() {
const now = new Date();
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
// Находим текущий временной слот
let currentSlot = null;
for (const slot of timeSlots) {
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
currentSlot = slot;
break;
}
}
if (currentSlot) {
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} ${currentSlot.endTime.substring(0, 5)})`;
renderCurrentLessons(currentSlot.orderNumber);
renderFreeClassrooms(currentSlot.orderNumber);
} else {
currentTimeSlot.textContent = 'Вне сетки пар';
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
renderFreeClassrooms(null);
}
}
function renderCurrentLessons(slotOrder) {
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
if (!activeLessons.length) {
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
return;
}
currentLessonsTbody.innerHTML = activeLessons.map(l => `
<tr>
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
</tr>
`).join('');
}
function renderFreeClassrooms(slotOrder) {
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
let occupiedIds = new Set();
if (slotOrder !== null) {
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
}
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
if (!free.length) {
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
return;
}
freeClassroomsList.innerHTML = free.map(c => `
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
${escapeHtml(c.name)} (${c.capacity} м.)
</span>
`).join('');
}
// --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() {
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
// Вычисляем даты начала и конца текущей недели
const curr = new Date();
const day = curr.getDay();
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
const monday = new Date(curr.setDate(diff));
const sunday = new Date(curr.setDate(diff + 6));
const mondayStr = monday.toISOString().split('T')[0];
const sundayStr = sunday.toISOString().split('T')[0];
try {
// Загружаем расписание на всю неделю по кафедрам
const weekLessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
);
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
const weekLessons = weekLessonsArrays.flat();
const conflicts = [];
// Мапы для быстрого поиска характеристик групп и аудиторий
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
// 1. Проверка накладок преподавателей
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
const teacherSlots = new Map();
weekLessons.forEach(l => {
if (!l.teacherId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
if (!teacherSlots.has(key)) {
teacherSlots.set(key, []);
}
teacherSlots.get(key).push(l);
});
teacherSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
const classroomIds = new Set(lessons.map(l => l.classroomId));
if (classroomIds.size > 1) {
const first = lessons[0];
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 2. Проверка накладок аудиторий
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
const classroomSlots = new Map();
weekLessons.forEach(l => {
if (!l.classroomId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
if (!classroomSlots.has(key)) {
classroomSlots.set(key, []);
}
classroomSlots.get(key).push(l);
});
classroomSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
const teacherIds = new Set(lessons.map(l => l.teacherId));
const subjectIds = new Set(lessons.map(l => l.subjectId));
if (teacherIds.size > 1 || subjectIds.size > 1) {
const first = lessons[0];
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 3. Проверка превышения вместимости аудитории
// Считаем сумму студентов для каждого уникального занятия в аудитории
classroomSlots.forEach((lessons, key) => {
if (!lessons.length) return;
const first = lessons[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
const groupIds = new Set();
lessons.forEach(l => {
if (l.groupIds) {
l.groupIds.forEach(id => groupIds.add(id));
}
});
let totalStudents = 0;
groupIds.forEach(id => {
totalStudents += groupSizeMap.get(id) || 0;
});
if (totalStudents > capacity) {
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'warning'
});
}
});
renderConflicts(conflicts);
} catch (e) {
console.error("Conflicts check error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
}
}
function renderConflicts(conflicts) {
if (!conflicts.length) {
conflictsContainer.innerHTML = `
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div>
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
</div>
</div>
`;
return;
}
conflictsContainer.innerHTML = conflicts.map(c => {
const isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
<div style="flex: 1;">
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
</div>
</div>
`;
}).join('');
}
// Загружаем данные при старте
await loadData();
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
const realtimeTimer = setInterval(() => {
if (document.getElementById('current-time-slot')) {
updateRealtimeMonitoring();
} else {
clearInterval(realtimeTimer);
}
}, 30000);
}

View File

@@ -12,6 +12,14 @@ export async function initDepartmentWorkspace() {
const endInput = document.getElementById('department-workspace-end'); const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh'); const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form'); const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add');
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
const assignTeacherSelect = document.getElementById('department-teacher-select');
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
const teacherRequestForm = document.getElementById('department-teacher-request-form');
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
const teacherRequestCount = document.getElementById('department-teacher-request-count');
const subjectsTbody = document.getElementById('department-subjects-tbody'); const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count'); const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card'); const commentsCard = document.getElementById('department-comments-card');
@@ -21,13 +29,30 @@ export async function initDepartmentWorkspace() {
const commentsSubjectInput = document.getElementById('department-comment-subject-id'); const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text'); const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close'); const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
setDefaultDates(startInput, endInput); setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace); refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace); departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', loadWorkload); startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload); endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects); importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
if (removeBtn) {
const rows = readImportRows();
rows.splice(Number(removeBtn.dataset.index), 1);
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
}
});
commentsForm?.addEventListener('submit', saveComment); commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => { commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true; commentsCard.hidden = true;
@@ -46,6 +71,8 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); await loadWorkspace();
} catch (error) { } catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error'); showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
@@ -53,7 +80,14 @@ export async function initDepartmentWorkspace() {
async function loadWorkspace() { async function loadWorkspace() {
hideAlert('department-workspace-alert'); hideAlert('department-workspace-alert');
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); lastTeachers = null;
lastWorkload = null;
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
} }
async function loadSubjects() { async function loadSubjects() {
@@ -73,7 +107,7 @@ export async function initDepartmentWorkspace() {
<td>${escapeHtml(subject.name || '-')}</td> <td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td> <td>${escapeHtml(subject.description || '-')}</td>
<td> <td>
<button type="button" class="btn-edit-classroom department-comments-open" <button type="button" class="btn btn-sm btn-secondary department-comments-open"
data-subject-id="${escapeHtml(subject.id)}" data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}"> data-subject-name="${escapeHtml(subject.name || '')}">
Открыть Открыть
@@ -87,44 +121,125 @@ export async function initDepartmentWorkspace() {
} }
} }
function renderImportRows(rows) {
if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}">
<div class="form-group">
<label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div>
<div class="form-group">
<label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div>
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readImportRows() {
if (!importRowsContainer) return [];
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
name: (row.querySelector('.subject-import-name')?.value || '').trim()
}));
}
async function importSubjects(event) { async function importSubjects(event) {
event.preventDefault(); event.preventDefault();
const alertId = 'department-subject-import-alert'; const alertId = 'department-subject-import-alert';
hideAlert(alertId); hideAlert(alertId);
const departmentId = selectedDepartmentId(); const departmentId = selectedDepartmentId();
const rows = document.getElementById('department-subject-import-text').value const rows = readImportRows()
.split('\n') .filter(item => item.name)
.map(row => row.trim()) .map(item => ({
.filter(Boolean) code: item.code,
.map(row => { name: item.name,
const [code, ...nameParts] = row.split(';'); departmentId: Number(departmentId)
return { }));
code: (code || '').trim(),
name: nameParts.join(';').trim(),
departmentId: Number(departmentId)
};
})
.filter(item => item.name);
if (!departmentId) { if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error'); showAlert(alertId, 'Выберите кафедру', 'error');
return; return;
} }
if (!rows.length) { if (!rows.length) {
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error'); showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
return; return;
} }
try { try {
const result = await api.post('/api/department/subjects/import', rows); const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success'); showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
document.getElementById('department-subject-import-text').value = ''; renderImportRows([{ code: '', name: '' }]);
await loadSubjects(); await loadSubjects();
} catch (error) { } catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error'); showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
} }
} }
async function assignTeacherToDepartment(event) {
event.preventDefault();
const alertId = 'department-teacher-assign-alert';
hideAlert(alertId);
const teacherId = assignTeacherSelect?.value;
const departmentId = selectedDepartmentId();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!teacherId) {
showAlert(alertId, 'Выберите преподавателя', 'error');
return;
}
try {
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
departmentId: Number(departmentId),
comment: assignTeacherComment?.value.trim() || null
});
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
if (assignTeacherComment) assignTeacherComment.value = '';
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (error) {
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
}
}
async function submitTeacherRequest(event) {
event.preventDefault();
const alertId = 'department-teacher-request-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const username = document.getElementById('teacher-request-username')?.value.trim();
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
const comment = document.getElementById('teacher-request-comment')?.value.trim();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!username || !fullName) {
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
return;
}
try {
await api.post('/api/department/teacher-requests', {
departmentId: Number(departmentId),
username,
fullName,
jobTitle,
comment
});
showAlert(alertId, 'Заявка отправлена администратору', 'success');
teacherRequestForm.reset();
await loadTeacherRequests();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
}
}
async function openComments(subjectId, subjectName) { async function openComments(subjectId, subjectName) {
commentsCard.hidden = false; commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`; commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
@@ -172,25 +287,60 @@ export async function initDepartmentWorkspace() {
} }
async function loadTeachers() { async function loadTeachers() {
const container = document.getElementById('department-teachers-list'); lastTeachers = null;
container.textContent = 'Загрузка...'; teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try { try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
container.innerHTML = teachers.length lastTeachers = Array.isArray(teachers) ? teachers : [];
? teachers.map(teacher => ` renderTeachers();
<article class="metric-card"> renderTeacherSelect();
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`; lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
} }
} }
async function loadWorkload() { async function loadWorkload() {
const container = document.getElementById('department-workload-list'); if (!startInput.value || !endInput.value) {
lastWorkload = [];
workloadErrorMessage = 'Выберите начало и окончание периода';
renderTeachers();
return;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: startInput.value, startDate: startInput.value,
endDate: endInput.value endDate: endInput.value
@@ -198,23 +348,127 @@ export async function initDepartmentWorkspace() {
if (selectedDepartmentId()) { if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId()); params.set('departmentId', selectedDepartmentId());
} }
container.textContent = 'Загрузка...'; lastWorkload = null;
workloadErrorMessage = '';
renderTeachers();
try { try {
const workload = await api.get(`/api/workload/teachers?${params}`); const workload = await api.get(`/api/workload/teachers?${params}`);
container.innerHTML = workload.length lastWorkload = Array.isArray(workload) ? workload : [];
? workload.map(item => ` renderTeachers();
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`; lastWorkload = [];
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
renderTeachers();
} }
} }
async function handleStartDateChange() {
if (startInput.value && endInput.value && startInput.value > endInput.value) {
endInput.value = '';
}
await loadWorkload();
}
function renderTeachers() {
const container = document.getElementById('department-teachers-list');
if (!container) return;
if (lastTeachers === null || lastWorkload === null) {
container.textContent = 'Загрузка...';
return;
}
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
workload
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = teachers.map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return renderTeacherMetric({
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
}, workloadErrorMessage);
});
workload
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push(renderTeacherMetric({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
}, workloadErrorMessage));
});
if (rows.length) {
container.innerHTML = rows.join('');
return;
}
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() { function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
} }
@@ -261,3 +515,60 @@ function subjectCountLabel(count) {
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`; return `${count} дисциплин`;
} }
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
function workloadLabel(item, workloadErrorMessage) {
if (workloadErrorMessage) {
return workloadErrorMessage;
}
if (!item) {
return '0 ак. ч., 0 занятий за период';
}
const hours = String(item.academicHours ?? 0);
const lessons = String(item.lessonCount ?? 0);
return `${hours} ак. ч., ${lessons} занятий за период`;
}
function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}

View File

@@ -1,226 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDepartmentsData() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
let departments = [];
let specialties = [];
async function loadData() {
// Load Departments
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
// Load Specialties
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
await loadData();
}

View File

@@ -1,90 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
export let allEquipments = [];
export async function fetchEquipments() {
try {
allEquipments = await api.get('/api/equipments');
return allEquipments;
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
container.innerHTML = equipments.map(eq => {
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}>
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label>
`}).join('');
updateSelectText(containerId, textId);
}
export async function initEquipments() {
const equipmentsTbody = document.getElementById('equipments-tbody');
const createEquipmentForm = document.getElementById('create-equipment-form');
async function loadEquipments() {
try {
const equipments = await fetchEquipments();
renderEquipments(equipments);
} catch (e) {
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEquipments(equipments) {
if (!equipments || !equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
createEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-equipment-alert');
const name = document.getElementById('new-equipment-name').value.trim();
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/equipments', { name });
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
createEquipmentForm.reset();
loadEquipments();
} catch (e) {
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
}
});
equipmentsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить оборудование?')) return;
try {
await api.delete('/api/equipments/' + btn.dataset.id);
loadEquipments();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEquipments();
}

View File

@@ -1,6 +1,16 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { fetchEducationForms } from './edu-forms.js';
async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error("Failed to fetch education forms", e);
return [];
}
}
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() { export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody'); const groupsTbody = document.getElementById('groups-tbody');
@@ -9,7 +19,12 @@ export async function initGroups() {
const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile'); const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfSelect = document.getElementById('filter-ef'); const filterEfBox = document.getElementById('filter-ef-box');
const filterEfMenu = document.getElementById('filter-ef-menu');
const filterEfText = document.getElementById('filter-ef-text');
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
// Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group'); const modalEditGroup = document.getElementById('modal-edit-group');
const modalEditGroupClose = document.getElementById('modal-edit-group-close'); const modalEditGroupClose = document.getElementById('modal-edit-group-close');
const editGroupForm = document.getElementById('edit-group-form'); const editGroupForm = document.getElementById('edit-group-form');
@@ -17,19 +32,30 @@ export async function initGroups() {
const editGroupDepartmentSelect = document.getElementById('edit-group-department'); const editGroupDepartmentSelect = document.getElementById('edit-group-department');
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
const editGroupProfileSelect = document.getElementById('edit-group-profile'); const editGroupProfileSelect = document.getElementById('edit-group-profile');
const calendarForm = document.getElementById('group-calendar-form');
const calendarGroupSelect = document.getElementById('calendar-group'); // Модалка подгрупп
const calendarYearSelect = document.getElementById('calendar-year'); const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
const calendarSelect = document.getElementById('calendar-id'); const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); const subgroupsGroupContext = document.getElementById('subgroups-group-context');
const subgroupForm = document.getElementById('subgroup-form'); const subgroupFormTitle = document.getElementById('subgroup-form-title');
const subgroupIdInput = document.getElementById('subgroup-id'); const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const subgroupGroupSelect = document.getElementById('subgroup-group'); const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const subgroupNameInput = document.getElementById('subgroup-name'); const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const subgroupCapacityInput = document.getElementById('subgroup-capacity'); const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
const subgroupSubmitButton = document.getElementById('subgroup-submit'); const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
const subgroupResetButton = document.getElementById('subgroup-reset'); const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const subgroupsTbody = document.getElementById('subgroups-tbody'); const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
// Модалка календарей
const modalManageCalendar = document.getElementById('modal-manage-calendar');
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
const calendarGroupContext = document.getElementById('calendar-group-context');
const manageCalendarForm = document.getElementById('manage-calendar-form');
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
const manageCalendarSelect = document.getElementById('manage-calendar-id');
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
let allGroups = []; let allGroups = [];
let subgroups = []; let subgroups = [];
@@ -40,33 +66,77 @@ export async function initGroups() {
let academicYears = []; let academicYears = [];
let calendars = []; let calendars = [];
let currentAssignments = []; let currentAssignments = [];
let activeSubgroupGroup = null;
bindEvents(); bindEvents();
await loadInitialData(); await loadInitialData();
function bindEvents() { function bindEvents() {
filterEfSelect.addEventListener('change', applyGroupFilter); initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
calendarGroupSelect.addEventListener('change', () => {
populateCalendarSelect();
loadAssignments();
});
calendarYearSelect.addEventListener('change', populateCalendarSelect);
createGroupForm.addEventListener('submit', createGroup); createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup); editGroupForm.addEventListener('submit', updateGroup);
calendarForm.addEventListener('submit', saveAssignment);
subgroupForm.addEventListener('submit', saveSubgroup); // Модалка подгрупп
subgroupGroupSelect.addEventListener('change', renderSubgroups); manageSubgroupForm.addEventListener('submit', saveSubgroup);
subgroupResetButton.addEventListener('click', resetSubgroupForm); manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
groupsTbody.addEventListener('click', handleGroupTableClick); groupsTbody.addEventListener('click', handleGroupTableClick);
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); // Закрытие модалок
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => { modalEditGroup.addEventListener('click', (event) => {
if (event.target === modalEditGroup) { if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
modalEditGroup.classList.remove('open'); });
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
modalManageSubgroups.addEventListener('click', (event) => {
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
});
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
modalManageCalendar.addEventListener('click', (event) => {
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
});
}
function initFilterEfDropdown() {
if (!filterEfBox || !filterEfMenu) return;
const toggleDropdown = (event) => {
event.stopPropagation();
const isOpen = filterEfMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (!isOpen) {
filterEfMenu.classList.add('open');
filterEfBox.classList.add('active');
filterEfBox.setAttribute('aria-expanded', 'true');
} else {
filterEfBox.setAttribute('aria-expanded', 'false');
} }
};
filterEfBox.addEventListener('click', toggleDropdown);
filterEfBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleDropdown(event);
}
});
filterEfMenu.addEventListener('click', (event) => {
event.stopPropagation();
}); });
} }
@@ -84,12 +154,10 @@ export async function initGroups() {
populateDepartmentSelects(departments); populateDepartmentSelects(departments);
populateSpecialitySelects(specialties); populateSpecialitySelects(specialties);
populateYearSelect(); populateYearSelect();
populateCalendarSelect();
await loadGroups(); await loadGroups();
await loadSubgroups(); await loadSubgroups();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -103,51 +171,104 @@ export async function initGroups() {
async function loadGroups() { async function loadGroups() {
try { try {
allGroups = await api.get('/api/groups'); allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter(); applyGroupFilter();
populateGroupSelect();
populateSubgroupGroupSelect();
await loadAssignments();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
async function loadSubgroups() { async function loadSubgroups() {
try { try {
subgroups = await api.get('/api/subgroups'); subgroups = await api.get('/api/subgroups');
renderSubgroups();
} catch (error) { } catch (error) {
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; console.error("Failed to load subgroups", error);
} }
} }
function applyGroupFilter() { function applyGroupFilter() {
const filterId = filterEfSelect.value; const selectedIds = selectedFilterEducationFormIds();
const filtered = filterId const filtered = selectedIds.length
? allGroups.filter(group => group.educationFormId == filterId) ? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups; : allGroups;
renderGroups(filtered); renderGroups(filtered);
} }
function handleFilterEfChange(event) {
const changedCheckbox = event.target.closest('input[type="checkbox"]');
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
checkbox.checked = false;
});
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
allCheckbox.checked = false;
}
if (!selectedFilterEducationFormIds().length) {
allCheckbox.checked = true;
}
updateFilterEfText();
applyGroupFilter();
}
function selectedFilterEducationFormIds() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (allCheckbox?.checked) return [];
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
.map(checkbox => String(checkbox.value));
}
function updateFilterEfText() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
const selectedIds = selectedFilterEducationFormIds();
if (allCheckbox?.checked) {
filterEfText.textContent = 'Все';
return;
}
if (!selectedIds.length) {
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
return;
}
if (selectedIds.length === 1) {
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
return;
}
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
}
function renderGroups(groups) { function renderGroups(groups) {
if (!groups || !groups.length) { if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
return; return;
} }
groupsTbody.innerHTML = groups.map(group => ` groupsTbody.innerHTML = groups.map(group => `
<tr> <tr>
<td>${group.id}</td> <td>${group.id}</td>
<td>${escapeHtml(group.name)}</td> <td><strong>${escapeHtml(group.name)}</strong></td>
<td>${escapeHtml(String(group.groupSize))}</td> <td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td> <td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td> <td>${escapeHtml(departmentLabel(group.departmentId))}</td>
<td>${group.course || '-'}</td> <td>${group.course || '-'}</td>
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td> <td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td> <td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td style="text-align: right;"> <td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button> <td class="group-actions-cell">
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button> <div class="group-actions">
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
${group.status === 'ARCHIVED'
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -156,11 +277,30 @@ export async function initGroups() {
function populateEfSelects(forms) { function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms); populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms); populateEfSelect(editGroupEfSelect, forms);
const currentFilter = filterEfSelect.value; renderFilterEfCheckboxes(forms);
filterEfSelect.innerHTML = '<option value="">Все формы</option>' + }
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentFilter) filterEfSelect.value = currentFilter; function renderFilterEfCheckboxes(forms) {
syncSelects(filterEfSelect); const selectedIds = new Set(selectedFilterEducationFormIds());
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
<span class="checkmark"></span>
<span>Все</span>
</label>
${forms.map(form => {
const id = String(form.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(form.name)}</span>
</label>
`;
}).join('')}
`;
updateFilterEfText();
} }
function populateEfSelect(select, forms) { function populateEfSelect(select, forms) {
@@ -222,41 +362,18 @@ export async function initGroups() {
} }
function populateYearSelect() { function populateYearSelect() {
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' + manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join(''); academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (!calendarYearSelect.value && academicYears.length) { if (academicYears.length) {
calendarYearSelect.value = String(academicYears[0].id); manageCalendarYearSelect.value = String(academicYears[0].id);
} }
syncSelects(calendarYearSelect); syncSelects(manageCalendarYearSelect);
}
function populateGroupSelect() {
const current = calendarGroupSelect.value;
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && allGroups.some(group => String(group.id) === String(current))) {
calendarGroupSelect.value = current;
} else if (allGroups.length) {
calendarGroupSelect.value = String(allGroups[0].id);
}
syncSelects(calendarGroupSelect);
}
function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value;
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && allGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current;
} else if (allGroups.length) {
subgroupGroupSelect.value = String(allGroups[0].id);
}
syncSelects(subgroupGroupSelect);
} }
function populateCalendarSelect() { function populateCalendarSelect() {
const group = selectedCalendarGroup(); const groupId = manageCalendarGroupIdInput.value;
const yearId = calendarYearSelect.value; const group = allGroups.find(g => g.id == groupId);
const yearId = manageCalendarYearSelect.value;
const matching = group && yearId const matching = group && yearId
? calendars.filter(calendar => ? calendars.filter(calendar =>
String(calendar.academicYearId) === String(yearId) String(calendar.academicYearId) === String(yearId)
@@ -265,12 +382,12 @@ export async function initGroups() {
&& String(calendar.studyFormId) === String(group.educationFormId) && String(calendar.studyFormId) === String(group.educationFormId)
) )
: []; : [];
calendarSelect.innerHTML = matching.length manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar => ? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('') ).join('')
: '<option value="">Нет подходящих графиков</option>'; : '<option value="">Нет подходящих графиков</option>';
syncSelects(calendarSelect); syncSelects(manageCalendarSelect);
} }
async function createGroup(event) { async function createGroup(event) {
@@ -285,7 +402,6 @@ export async function initGroups() {
createGroupForm.reset(); createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
} }
@@ -303,7 +419,6 @@ export async function initGroups() {
modalEditGroup.classList.remove('open'); modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
} }
@@ -340,23 +455,46 @@ export async function initGroups() {
} }
async function handleGroupTableClick(event) { async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-delete'); const btnDelete = event.target.closest('.btn-archive-group');
const btnEdit = event.target.closest('.btn-edit-group'); const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) { if (btnDelete) {
if (!confirm('Удалить группу?')) return; if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try { try {
await api.delete('/api/groups/' + btnDelete.dataset.id); await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка удаления'); alert(error.message || 'Ошибка архивирования');
}
}
if (btnRestore) {
try {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups();
} catch (error) {
alert(error.message || 'Ошибка восстановления');
} }
} }
if (btnEdit) { if (btnEdit) {
openEditGroupModal(btnEdit.dataset.id); openEditGroupModal(btnEdit.dataset.id);
} }
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openSubgroupsModal(group);
}
if (btnCalendar) {
const groupId = btnCalendar.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openCalendarModal(group);
}
} }
function openEditGroupModal(id) { function openEditGroupModal(id) {
@@ -379,109 +517,294 @@ export async function initGroups() {
modalEditGroup.classList.add('open'); modalEditGroup.classList.add('open');
} }
async function saveSubgroup(event) { // --- Управление подгруппами ---
event.preventDefault(); function openSubgroupsModal(group) {
hideAlert('subgroup-alert'); activeSubgroupGroup = group;
const groupId = subgroupGroupSelect.value; subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
const name = subgroupNameInput.value.trim(); manageSubgroupGroupIdInput.value = group.id;
const capacity = subgroupCapacityInput.value; resetSubgroupForm();
const subgroupId = subgroupIdInput.value; modalManageSubgroups.classList.add('open');
renderSubgroupsList(group.id);
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
const payload = {
name,
studentCapacity: capacity ? Number(capacity) : null
};
try {
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
const saved = subgroupId
? await api.put(endpoint, payload)
: await api.post(endpoint, payload);
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm();
await loadSubgroups();
} catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
} }
function renderSubgroups() { function renderSubgroupsList(groupId) {
const groupId = subgroupGroupSelect.value; const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
const visibleSubgroups = groupId if (!list.length) {
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId)) manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
: subgroups;
if (!visibleSubgroups.length) {
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
return; return;
} }
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => ` const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr> <tr>
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td> <td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(subgroup.name)}</td> <td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
} }
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
const count = [2, 3].includes(current.length) ? current.length : 0;
manageSubgroupCountSelect.value = String(count);
syncSelects(manageSubgroupCountSelect);
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.style.display = 'none';
hideAlert('manage-subgroup-alert');
}
function currentGroupSubgroups(groupId) {
return subgroups
.filter(sub => String(sub.groupId) === String(groupId))
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
function renderSubgroupCapacityFields(count, capacities = []) {
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
if (!count) {
manageSubgroupCapacities.innerHTML = '';
return;
}
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
const suggested = suggestedSubgroupCapacities(groupSize, count);
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
const value = capacities[index] ?? suggested[index] ?? '';
return `
<div class="form-group subgroup-capacity-field">
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
</div>
`;
}).join('');
}
function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
async function saveSubgroup(event) {
event.preventDefault();
hideAlert('manage-subgroup-alert');
const groupId = manageSubgroupGroupIdInput.value;
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const count = Number(manageSubgroupCountSelect.value);
const existingSubgroups = currentGroupSubgroups(groupId);
if (!group) {
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return;
}
if (count === 0) {
await applyNoSubgroups(groupId, existingSubgroups);
return;
}
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
.map(input => Number(input.value));
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
if (validationError) {
showAlert('manage-subgroup-alert', validationError, 'error');
return;
}
try {
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
}
function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
async function applyNoSubgroups(groupId, existingSubgroups) {
try {
for (const subgroup of existingSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
}
}
async function syncSubgroups(groupId, existingSubgroups, capacities) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
}
for (const payload of creates) {
await api.post(`/api/groups/${groupId}/subgroups`, payload);
}
}
async function handleSubgroupTableClick(event) { async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup'); const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup'); const deleteButton = event.target.closest('.btn-delete-subgroup');
const groupId = manageSubgroupGroupIdInput.value;
if (editButton) { if (editButton) {
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id)); const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return; if (!subgroup) return;
subgroupIdInput.value = subgroup.id;
subgroupGroupSelect.value = subgroup.groupId; resetSubgroupForm();
subgroupGroupSelect.disabled = true; const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
subgroupNameInput.value = subgroup.name || ''; const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
subgroupCapacityInput.value = subgroup.studentCapacity ?? ''; input?.focus();
subgroupSubmitButton.textContent = 'Сохранить подгруппу'; hideAlert('manage-subgroup-alert');
syncSelects(subgroupGroupSelect);
hideAlert('subgroup-alert');
return; return;
} }
if (deleteButton) { if (deleteButton) {
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id)); if (deleteButton.disabled) {
if (!subgroup || !confirm('Удалить подгруппу?')) return; showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!confirm('Удалить подгруппу?')) return;
try { try {
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`); await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('subgroup-alert', 'Подгруппа удалена', 'success'); showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm(); resetSubgroupForm();
await loadSubgroups(); await loadSubgroups();
renderSubgroupsList(groupId);
} catch (error) { } catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error'); showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
} }
} }
} }
function resetSubgroupForm() { // --- Управление календарными графиками ---
subgroupIdInput.value = ''; async function openCalendarModal(group) {
subgroupNameInput.value = ''; calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
subgroupCapacityInput.value = ''; manageCalendarGroupIdInput.value = group.id;
subgroupGroupSelect.disabled = false; hideAlert('manage-calendar-alert');
subgroupSubmitButton.textContent = 'Создать подгруппу'; populateCalendarSelect();
syncSelects(subgroupGroupSelect); modalManageCalendar.classList.add('open');
hideAlert('subgroup-alert'); await loadAssignmentsList(group.id);
}
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignmentsList();
} catch (error) {
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignmentsList() {
if (!currentAssignments.length) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
return;
}
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderAssignmentSubjects(subjects) {
if (!subjects.length) {
return '<span class="muted">Не привязаны</span>';
}
const grouped = subjects.reduce((acc, subject) => {
const key = String(subject.semesterNumber || '-');
if (!acc.has(key)) acc.set(key, []);
acc.get(key).push(subject);
return acc;
}, new Map());
return `<div class="assignment-subjects">${Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => `
<div class="assignment-subject-semester">
<strong>${escapeHtml(semesterNumber)} семестр</strong>
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
</div>
`).join('')}</div>`;
} }
async function saveAssignment(event) { async function saveAssignment(event) {
event.preventDefault(); event.preventDefault();
hideAlert('group-calendar-alert'); hideAlert('manage-calendar-alert');
const groupId = calendarGroupSelect.value; const groupId = manageCalendarGroupIdInput.value;
const academicYearId = calendarYearSelect.value; const academicYearId = manageCalendarYearSelect.value;
const calendarId = calendarSelect.value; const calendarId = manageCalendarSelect.value;
if (!groupId || !academicYearId || !calendarId) { if (!academicYearId || !calendarId) {
showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error'); showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
return; return;
} }
@@ -490,74 +813,36 @@ export async function initGroups() {
academicYearId: Number(academicYearId), academicYearId: Number(academicYearId),
calendarId: Number(calendarId) calendarId: Number(calendarId)
}); });
showAlert('group-calendar-alert', 'Календарный график назначен', 'success'); showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
await loadAssignments(); await loadAssignmentsList(groupId);
} catch (error) { } catch (error) {
showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error'); showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
} }
} }
async function loadAssignments() { manageCalendarTbody.addEventListener('click', async (event) => {
const groupId = calendarGroupSelect.value;
if (!groupId) {
currentAssignments = [];
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
return;
}
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignments();
} catch (error) {
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignments() {
if (!currentAssignments.length) {
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
return;
}
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleAssignmentTableClick(event) {
const deleteButton = event.target.closest('.btn-delete-assignment'); const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return; if (!deleteButton) return;
if (!confirm('Удалить назначение графика?')) return; if (!confirm('Удалить назначение графика?')) return;
const groupId = manageCalendarGroupIdInput.value;
try { try {
await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
showAlert('group-calendar-alert', 'Назначение удалено', 'success'); showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
await loadAssignments(); await loadAssignmentsList(groupId);
} catch (error) { } catch (error) {
showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
} }
} });
function selectedCalendarGroup() {
return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value));
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) { function departmentLabel(departmentId) {
const department = departments.find(item => item.id == departmentId); const department = departments.find(item => item.id == departmentId);
if (!department) return departmentId || '-'; if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId; return department.departmentName || department.name || departmentId;
} }
function groupName(groupId) {
const group = allGroups.find(item => String(item.id) === String(groupId));
return group ? group.name : groupId || '-';
}
function specialityLabel(specialityId) { function specialityLabel(specialityId) {
const speciality = specialties.find(item => item.id == specialityId); const speciality = specialties.find(item => item.id == specialityId);
if (!speciality) return specialityId || '-'; if (!speciality) return specialityId || '-';
@@ -566,6 +851,26 @@ export async function initGroups() {
return name ? `${code}${name}` : code; return name ? `${code}${name}` : code;
} }
function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
}
function statusBadgeClass(group) {
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
return 'badge-unavailable';
}
if (group.studyState === 'NOT_STARTED') {
return 'badge-ef';
}
return 'badge-available';
}
function syncSelects(...selects) { function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));

View File

@@ -1,192 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initProfiles() {
const createProfileForm = document.getElementById('create-profile-form');
const profileSpecialtySelect = document.getElementById('profile-specialty');
const profileNameInput = document.getElementById('profile-name');
const profileDescriptionInput = document.getElementById('profile-description');
const profilesTbody = document.getElementById('profiles-tbody');
const profilesContext = document.getElementById('profiles-context');
const refreshButton = document.getElementById('profiles-refresh');
const editProfileForm = document.getElementById('edit-profile-form');
const editProfileModal = document.getElementById('modal-edit-profile');
const editProfileClose = document.getElementById('modal-edit-profile-close');
let specialties = [];
let profiles = [];
await loadSpecialties();
profileSpecialtySelect.addEventListener('change', loadProfiles);
refreshButton.addEventListener('click', loadProfiles);
createProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('create-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const name = profileNameInput.value.trim();
const description = profileDescriptionInput.value.trim();
if (!specialtyId || !name) {
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
return;
}
try {
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
createProfileForm.reset();
profileSpecialtySelect.value = specialtyId;
syncSelect(profileSpecialtySelect);
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (event) => {
const editButton = event.target.closest('.btn-edit-profile');
const deleteButton = event.target.closest('.btn-delete-profile');
if (editButton) {
const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id));
if (!profile) return;
document.getElementById('edit-profile-id').value = profile.id;
document.getElementById('edit-profile-name').value = profile.name || '';
document.getElementById('edit-profile-description').value = profile.description || '';
hideAlert('edit-profile-alert');
editProfileModal.classList.add('open');
return;
}
if (deleteButton) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`);
showAlert('create-profile-alert', 'Профиль удалён', 'success');
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
editProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('edit-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const id = document.getElementById('edit-profile-id').value;
const name = document.getElementById('edit-profile-name').value.trim();
const description = document.getElementById('edit-profile-description').value.trim();
if (!specialtyId || !id || !name) {
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
return;
}
try {
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
editProfileModal.classList.remove('open');
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
await loadProfiles();
} catch (error) {
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
}
});
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
editProfileModal.addEventListener('click', (event) => {
if (event.target === editProfileModal) {
editProfileModal.classList.remove('open');
}
});
async function loadSpecialties() {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
specialties = await api.get('/api/specialties');
renderSpecialtySelect();
await loadProfiles();
} catch (error) {
profileSpecialtySelect.innerHTML = '<option value="">Не удалось загрузить специальности</option>';
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
syncSelect(profileSpecialtySelect);
}
}
function renderSpecialtySelect() {
if (!specialties.length) {
profileSpecialtySelect.innerHTML = '<option value="">Сначала создайте специальность</option>';
syncSelect(profileSpecialtySelect);
return;
}
const current = profileSpecialtySelect.value;
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(specialty => {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `<option value="${specialty.id}">${escapeHtml(code)}${escapeHtml(name)}</option>`;
}).join('');
if (current && specialties.some(specialty => String(specialty.id) === String(current))) {
profileSpecialtySelect.value = current;
} else {
profileSpecialtySelect.value = String(specialties[0].id);
}
syncSelect(profileSpecialtySelect);
}
async function loadProfiles() {
const specialtyId = profileSpecialtySelect.value;
const specialty = specialties.find(item => String(item.id) === String(specialtyId));
profilesContext.textContent = specialty ? specialtyLabel(specialty) : '';
if (!specialtyId) {
profiles = [];
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
return;
}
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
renderProfiles();
} catch (error) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderProfiles() {
if (!profiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = profiles.map(profile => `
<tr>
<td>${profile.id}</td>
<td>${escapeHtml(profile.name)}</td>
<td>${escapeHtml(profile.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function specialtyLabel(specialty) {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `${code} · ${name}`;
}
function syncSelect(select) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}

Some files were not shown because too many files have changed in this diff Show More