66 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
f08db3f178 Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule 2026-05-25 13:40:30 +03:00
dipatrik10
c506a905a2 Немного исправил сетку графика 2026-05-25 13:40:22 +03:00
Zuev
f49fa95d5a исправил просмотр расписаний 2026-05-20 15:02:43 +03:00
Zuev
b1b85c3d17 поправил просмотр расписания 2026-05-20 01:29:07 +03:00
Zuev
91729cff7d ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания 2026-05-19 23:10:56 +03:00
Zuev
f010ffc467 план ролевого доступа 2026-05-19 15:02:07 +03:00
Zuev
670e0a51ad сделал просмотр загруженности конкретной аудитории 2026-05-15 02:12:58 +03:00
Zuev
02c5ea9118 подправил создание правил расписания 2026-05-08 13:41:25 +03:00
Zuev
e12a5bcccd добавил денение на подгруппы для лабораторных работ 2026-05-06 22:25:53 +03:00
Zuev
b222f3adac написал обработку количества часов лекций лабораторных и практик по отдельности 2026-05-06 21:54:17 +03:00
Zuev
44cfe8ab6e исправил настройку временных слотов 2026-05-02 01:34:21 +03:00
Zuev
3cb311f469 сделал настройку временных слотов 2026-05-01 20:55:49 +03:00
Zuev
bf02efb8b8 доработал отображение учебного графика 2026-05-01 14:02:01 +03:00
Zuev
89c822a073 создал систему календарного учебного графика 2026-04-30 23:40:49 +03:00
Zuev
96e9d8155f Миллион изменений, создание вкладки "Расписание занятий" 2026-04-29 23:35:16 +03:00
Zuev
8f71b9b2b5 начало работы построения динамического расписания и документация 2026-04-29 15:42:05 +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
dipatrik10
813e81be70 Удалил в2 2026-04-09 21:20:11 +03:00
dipatrik10
e92aa74048 Собрал БД в 1 файл 2026-04-09 21:19:35 +03:00
Zuev
48e8d4e631 добавил файл с задачами по денамической генерации занятий 2026-04-07 17:42:58 +03:00
Zuev
c7145de95a поправил документ описывающий концепцияю динамической генерации расписания 2026-04-07 01:10:44 +03:00
Zuev
c7594c4380 добавил архитектурное описание новой системы управления расписанием 2026-04-04 23:56:39 +03:00
ac69a57290 Зеркалирование "Создать занятие" в "Расписание занятий" из "Пользователи", визуальные изменения этих модалок 2026-04-02 00:09:19 +03:00
c82e3feaed Изменил страницу "Кафедра", добавлены изменения из задачи #54 в Vikunja 2026-04-01 23:18:05 +03:00
ProstoDenya01
3cdb8614cb Изменил логику в группах, вместо семестра теперь пишется год начала обучения, чтобы курс и семестр считались на бэке 2026-03-31 16:44:44 +03:00
Zuev
73995f86f8 добавил тестовую страницу загруженности аудиторий 2026-03-31 15:00:38 +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
241 changed files with 29700 additions and 6038 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/`, обновить все вхождения

21
.codex/config.toml Normal file
View File

@@ -0,0 +1,21 @@
#:schema https://developers.openai.com/codex/config-schema.json
web_search = "live"
developer_instructions = """
Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
"""
[skills]
include_instructions = true
[[skills.config]]
path = "../.agents/skills/auto-update-docs"
enabled = true
[[skills.config]]
path = "../.agents/skills/frontend-design"
enabled = true

3
.gitignore vendored
View File

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

View File

@@ -27,6 +27,8 @@ magistr/
├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта
@@ -60,7 +62,7 @@ docker compose logs -f backend
## Критические правила для агентов
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway.
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`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

@@ -50,6 +50,12 @@
<artifactId>spring-security-crypto</artifactId>
</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 когда нет настроенных тенантов) -->
<dependency>
<groupId>com.h2database</groupId>
@@ -63,6 +69,13 @@
<artifactId>opentelemetry-api</artifactId>
<version>1.49.0</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,26 @@
package com.magistr.app.config.auth;
public final class AuthContext {
private static final ThreadLocal<AuthenticatedUser> CURRENT_USER = new ThreadLocal<>();
private AuthContext() {
}
public static void setCurrentUser(AuthenticatedUser user) {
CURRENT_USER.set(user);
}
public static AuthenticatedUser getCurrentUser() {
return CURRENT_USER.get();
}
public static Long currentUserId() {
AuthenticatedUser user = getCurrentUser();
return user == null ? null : user.id();
}
public static void clear() {
CURRENT_USER.remove();
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) {
}

View File

@@ -0,0 +1,93 @@
package com.magistr.app.config.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.model.Role;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper;
public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (isPublicAuthEndpoint(request)) {
return true;
}
String token = bearerToken(request.getHeader("Authorization"));
AuthenticatedUser user = jwtTokenService.authenticate(
token,
com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
).orElse(null);
if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false;
}
AuthContext.setCurrentUser(user);
RequireRoles roles = resolveRoles(handler);
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
return false;
}
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
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
}
private RequireRoles resolveRoles(Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return null;
}
RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class);
if (methodRoles != null) {
return methodRoles;
}
return handlerMethod.getBeanType().getAnnotation(RequireRoles.class);
}
private String bearerToken(String header) {
if (header == null || !header.startsWith("Bearer ")) {
return null;
}
return header.substring("Bearer ".length()).trim();
}
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getOutputStream(), Map.of("message", message));
}
}

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

@@ -0,0 +1,14 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequireRoles {
Role[] value();
}

View File

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

View File

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

View File

@@ -12,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
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 javax.sql.DataSource;
@@ -29,7 +27,7 @@ import java.util.*;
* как заглушку, чтобы Spring JPA мог инициализироваться.
*/
@Configuration
public class TenantDataSourceConfig implements WebMvcConfigurer {
public class TenantDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
@@ -67,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
try {
routingDataSource.addTenant(tenant);
} catch (Exception e) {
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
}
}
@@ -119,23 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
return new JpaTransactionManager(emf);
}
@org.springframework.context.annotation.Lazy
@org.springframework.beans.factory.annotation.Autowired
private TenantRoutingDataSource tenantRoutingDataSource;
@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("/**");
}
private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
@@ -149,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
return list;
} catch (IOException e) {
log.error("Failed to read tenants config: {}", e.getMessage());
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
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

@@ -0,0 +1,282 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/calendar")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping("/years")
public List<AcademicYearDto> getYears() {
return academicYearRepository.findAll().stream()
.sorted(Comparator.comparing(AcademicYear::getStartDate).reversed())
.map(this::toAcademicYearDto)
.toList();
}
@PostMapping("/years")
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
}
AcademicYear year = new AcademicYear();
applyYear(year, request);
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
}
@PutMapping("/years/{id}")
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
AcademicYear year = academicYearRepository.findById(id).orElse(null);
if (year == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applyYear(year, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
}
@DeleteMapping("/years/{id}")
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
if (!academicYearRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
academicYearRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
}
@GetMapping("/years/{academicYearId}/semesters")
public List<SemesterDto> getSemesters(@PathVariable Long academicYearId) {
return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream()
.map(this::toSemesterDto)
.toList();
}
@PostMapping("/years/{academicYearId}/semesters")
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
if (year == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
}
Semester semester = new Semester();
semester.setAcademicYear(year);
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
}
@PutMapping("/semesters/{id}")
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
Semester semester = semesterRepository.findById(id).orElse(null);
if (semester == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
}
@GetMapping("/activity-types")
public List<AcademicCalendarActivityTypeDto> getActivityTypes() {
return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream()
.map(this::toActivityTypeDto)
.toList();
}
@PostMapping("/activity-types")
public ResponseEntity<?> createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) {
String validationError = validateActivityType(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
}
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
applyActivityType(activityType, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
}
@PutMapping("/activity-types/{id}")
public ResponseEntity<?> updateActivityType(@PathVariable Long id,
@RequestBody AcademicCalendarActivityTypeDto request) {
AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null);
if (activityType == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateActivityType(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (activityTypeRepository.findByCode(request.code().trim())
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
}
applyActivityType(activityType, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
}
@DeleteMapping("/activity-types/{id}")
public ResponseEntity<?> deleteActivityType(@PathVariable Long id) {
if (!activityTypeRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
if (calendarDayRepository.existsByActivityTypeId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках"));
}
activityTypeRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
}
private String validateYear(AcademicYearDto request) {
if (request.title() == null || request.title().isBlank()) {
return "Название учебного года обязательно";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания учебного года обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания не может быть раньше даты начала";
}
return null;
}
private String validateSemester(SemesterDto request) {
if (request.semesterType() == null) {
return "Тип семестра обязателен";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания семестра обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания семестра не может быть раньше даты начала";
}
return null;
}
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
if (request == null) {
return "Передайте код активности";
}
if (request.code() == null || request.code().isBlank()) {
return "Код активности обязателен";
}
if (request.name() == null || request.name().isBlank()) {
return "Название активности обязательно";
}
return null;
}
private void applyYear(AcademicYear year, AcademicYearDto request) {
year.setTitle(request.title().trim());
year.setStartDate(request.startDate());
year.setEndDate(request.endDate());
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
semester.setEndDate(request.endDate());
}
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
activityType.setCode(request.code().trim());
activityType.setName(request.name().trim());
activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule()));
activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank()
? "#64748b"
: request.colorCode().trim());
activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder());
activityType.setDescription(request.description() == null || request.description().isBlank()
? null
: request.description().trim());
}
private AcademicYearDto toAcademicYearDto(AcademicYear year) {
List<SemesterDto> semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId())
.stream()
.map(this::toSemesterDto)
.toList();
return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters);
}
private SemesterDto toSemesterDto(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) {
return new AcademicCalendarActivityTypeDto(
activityType.getId(),
activityType.getCode(),
activityType.getName(),
activityType.getAllowSchedule(),
activityType.getColorCode(),
activityType.getDisplayOrder(),
activityType.getDescription()
);
}
}

View File

@@ -0,0 +1,352 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.activityTypeRepository = activityTypeRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping
public List<AcademicCalendarDto> getCalendars(@RequestParam(required = false) Long academicYearId,
@RequestParam(required = false) Long specialtyId,
@RequestParam(required = false) Long profileId) {
return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream()
.map(this::toCalendarDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
return calendarRepository.findByIdWithDetails(id)
.<ResponseEntity<?>>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try {
AcademicCalendar calendar = new AcademicCalendar();
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteCalendar(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
}
}
@GetMapping("/{id}/grid")
public ResponseEntity<?> getGrid(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream()
.map(this::toGridDayDto)
.toList());
}
@PutMapping("/{id}/grid")
@Transactional
public ResponseEntity<?> saveGrid(@PathVariable Long id, @RequestBody List<AcademicCalendarGridDayDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
if (rows == null || rows.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика"));
}
try {
calendarDayRepository.deleteByCalendarId(id);
for (AcademicCalendarGridDayDto row : rows) {
AcademicCalendarDay day = buildGridDay(calendar, row);
calendarDayRepository.save(day);
}
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@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) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
}
AcademicYear year = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
}
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(request.title().trim());
calendar.setAcademicYear(year);
calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile);
calendar.setStudyForm(studyForm);
calendar.setCourseCount(request.courseCount());
}
private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null || row.courseNumber() <= 0
|| row.date() == null
|| row.weekNumber() == null || row.weekNumber() <= 0
|| row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) {
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
}
if (row.date().isBefore(calendar.getAcademicYear().getStartDate())
|| row.date().isAfter(calendar.getAcademicYear().getEndDate())) {
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
}
if (row.courseNumber() > calendar.getCourseCount()) {
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
}
AcademicCalendarActivityType activityType = resolveActivityType(row);
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(activityType);
return day;
}
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
if (row.activityTypeId() != null) {
return activityTypeRepository.findById(row.activityTypeId())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
if (row.activityCode() != null && !row.activityCode().isBlank()) {
return activityTypeRepository.findByCode(row.activityCode().trim())
.orElseThrow(() -> 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) {
AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality();
SpecialtyProfile profile = calendar.getSpecialtyProfile();
EducationForm studyForm = calendar.getStudyForm();
return new AcademicCalendarDto(
calendar.getId(),
calendar.getTitle(),
year.getId(),
year.getTitle(),
year.getStartDate(),
year.getEndDate(),
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getId(),
profile.getName(),
studyForm.getId(),
studyForm.getName(),
calendar.getCourseCount()
);
}
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) {
AcademicCalendarActivityType activityType = day.getActivityType();
return new AcademicCalendarGridDayDto(
day.getId(),
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate(),
day.getWeekNumber(),
day.getDayOfWeek(),
activityType.getId(),
activityType.getCode(),
activityType.getName(),
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,16 +1,27 @@
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.LoginResponse;
import com.magistr.app.model.User;
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.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
@@ -18,20 +29,33 @@ public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
"EDUCATION_OFFICE", "/admin/#schedule-view",
"DEPARTMENT", "/admin/#department-workspace",
"SCHEDULE_VIEWER", "/admin/#schedule-view",
"TEACHER", "/teacher/",
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
}
@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());
if (userOpt.isEmpty() ||
@@ -42,11 +66,137 @@ public class AuthController {
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
if (user.isArchivedRecord()) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
String tenant = TenantContext.getCurrentTenant();
String accessToken = jwtTokenService.createAccessToken(user, tenant);
String refreshToken = refreshTokenService.createSession(
user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIp(servletRequest)
);
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
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")
public ResponseEntity<?> me() {
var user = AuthContext.getCurrentUser();
if (user == null) {
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("userId", user.id());
response.put("username", user.username());
response.put("role", user.role().name());
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

@@ -1,9 +1,12 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomRequest;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Equipment;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -15,6 +18,7 @@ import java.util.Optional;
@RestController
@RequestMapping("/api/classrooms")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ClassroomController {
private final ClassroomRepository classroomRepository;
@@ -26,8 +30,12 @@ public class ClassroomController {
}
@GetMapping
public List<ClassroomResponse> getAllClassrooms() {
return classroomRepository.findAll().stream()
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
List<Classroom> classrooms = includeArchived
? classroomRepository.findAll()
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
return classrooms.stream()
.map(this::mapToResponse)
.toList();
}
@@ -47,10 +55,14 @@ public class ClassroomController {
Classroom classroom = new Classroom();
classroom.setName(request.getName().trim());
classroom.setCapacity(request.getCapacity());
classroom.setBuilding(cleanText(request.getBuilding()));
classroom.setFloor(request.getFloor());
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -80,12 +92,23 @@ public class ClassroomController {
classroom.setCapacity(request.getCapacity());
}
if (request.getBuilding() != null) {
classroom.setBuilding(cleanText(request.getBuilding()));
}
if (request.getFloor() != null) {
classroom.setFloor(request.getFloor());
}
if (request.getIsAvailable() != null) {
classroom.setIsAvailable(request.getIsAvailable());
}
if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
equipments = equipments.stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -95,15 +118,37 @@ public class ClassroomController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
classroomRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
Classroom classroom = classroomOpt.get();
classroom.archive("Выведена из эксплуатации");
classroom.setIsAvailable(false);
classroomRepository.save(classroom);
return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreClassroom(@PathVariable Long id) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Classroom classroom = classroomOpt.get();
classroom.restore();
classroom.setIsAvailable(true);
classroomRepository.save(classroom);
return ResponseEntity.ok(mapToResponse(classroom));
}
private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
new java.util.ArrayList<>(c.getEquipments()));
}
private String cleanText(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -1,10 +1,12 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.config.tenant.ConfigMapUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +26,7 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/api/database")
@RequireRoles({Role.ADMIN})
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;

View File

@@ -1,8 +1,11 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,6 +18,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/departments")
@RequireRoles({Role.ADMIN})
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
@@ -26,10 +30,13 @@ public class DepartmentController {
}
@GetMapping
public List<Department> getAllDepartments() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = departmentRepository.findAll();
List<Department> departments = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
@@ -92,15 +99,87 @@ public class DepartmentController {
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(id)) {
@PutMapping("/{id}")
public ResponseEntity<?> updateDepartment(@PathVariable Long id, @RequestBody CreateDepartmentRequest request) {
logger.info("Получен запрос на обновление кафедры с ID: {}", id);
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
departmentRepository.deleteById(id);
logger.info("Кафедра с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
try {
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()) {
String errorMessage = "Название кафедры обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
String errorMessage = "Код кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
String departmentName = request.getDepartmentName().trim();
if (departmentRepository.findByDepartmentName(departmentName)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Кафедра с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode())
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Кафедра с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
department.setDepartmentName(departmentName);
department.setDepartmentCode(request.getDepartmentCode());
departmentRepository.save(department);
logger.info("Кафедра с ID - {} успешно обновлена", id);
return ResponseEntity.ok(new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
department.archive("Кафедра архивирована");
departmentRepository.save(department);
logger.info("Кафедра с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Кафедра архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreDepartment(@PathVariable Long id) {
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
return ResponseEntity.notFound().build();
}
department.restore();
departmentRepository.save(department);
return ResponseEntity.ok(new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
));
}
}

View File

@@ -0,0 +1,341 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest;
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.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository;
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.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/department")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping("/subjects")
public List<Subject> getSubjects(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping("/subjects/import")
public ResponseEntity<?> importSubjects(@RequestBody List<CreateSubjectRequest> requests) {
if (requests == null || requests.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки"));
}
Long requestedDepartmentId = requests.stream()
.map(CreateSubjectRequest::getDepartmentId)
.filter(id -> id != null && id > 0)
.findFirst()
.orElse(null);
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
List<Subject> saved = requests.stream()
.filter(request -> request.getName() != null && !request.getName().isBlank())
.map(request -> {
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
subject.setName(request.getName().trim());
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
return subjectRepository.save(subject);
})
.toList();
return ResponseEntity.ok(Map.of(
"message", "Дисциплины загружены",
"count", saved.size()
));
}
@GetMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> getComments(@PathVariable Long subjectId) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream()
.map(this::toCommentDto)
.toList());
}
@PostMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> addComment(@PathVariable Long subjectId, @RequestBody Map<String, String> request) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
String commentText = request == null ? null : request.get("comment");
if (commentText == null || commentText.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
}
SubjectComment comment = new SubjectComment();
comment.setSubject(subject);
comment.setComment(commentText.trim());
Long currentUserId = AuthContext.currentUserId();
if (currentUserId != null) {
userRepository.findById(currentUserId).ifPresent(comment::setAuthor);
}
return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment)));
}
@GetMapping("/teachers")
public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
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")
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId,
null, null, null, null, startDate, endDate));
}
private SubjectCommentDto toCommentDto(SubjectComment comment) {
User author = comment.getAuthor();
return new SubjectCommentDto(
comment.getId(),
comment.getSubject().getId(),
author == null ? null : author.getId(),
author == null ? null : author.getFullName(),
comment.getComment(),
comment.getCreatedAt()
);
}
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) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
}
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
}
private boolean canAccessDepartment(Long departmentId) {
var user = AuthContext.getCurrentUser();
return user == null
|| user.role() == Role.ADMIN
|| (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
}
}

View File

@@ -1,7 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import org.springframework.http.ResponseEntity;
@@ -12,20 +15,25 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;
private final GroupRepository groupRepository;
private final AcademicCalendarRepository academicCalendarRepository;
public EducationFormController(EducationFormRepository educationFormRepository,
GroupRepository groupRepository) {
GroupRepository groupRepository,
AcademicCalendarRepository academicCalendarRepository) {
this.educationFormRepository = educationFormRepository;
this.groupRepository = groupRepository;
this.academicCalendarRepository = academicCalendarRepository;
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Map<String, Object>> getAll() {
return educationFormRepository.findAll().stream()
return educationFormRepository.findAllByOrderByNameAsc().stream()
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
.toList();
}
@@ -54,12 +62,17 @@ public class EducationFormController {
return ResponseEntity.notFound().build();
}
// Check if any groups use this education form
// Нельзя удалить форму обучения, пока она используется группами или графиками.
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
if (!linked.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
}
long linkedCalendars = academicCalendarRepository.countByStudyFormId(id);
if (linkedCalendars > 0) {
return ResponseEntity.badRequest().body(Map.of(
"message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")"));
}
educationFormRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));

View File

@@ -1,5 +1,8 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Equipment;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -10,6 +13,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/equipments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EquipmentController {
private final EquipmentRepository equipmentRepository;
@@ -19,8 +23,8 @@ public class EquipmentController {
}
@GetMapping
public List<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping
@@ -42,10 +46,23 @@ public class EquipmentController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
if (!equipmentRepository.existsById(id)) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
equipment.archive("Оборудование архивировано");
equipmentRepository.save(equipment);
return ResponseEntity.ok(Map.of("message", "Оборудование архивировано"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreEquipment(@PathVariable Long id) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipment.restore();
equipmentRepository.save(equipment);
return ResponseEntity.ok(equipment);
}
}

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,54 +1,92 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.Subject;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/groups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
private final GroupRepository groupRepository;
private final EducationFormRepository educationFormRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository) {
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
}
@GetMapping
public List<GroupResponse> getAllGroups() {
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = groupRepository.findAll();
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
List<GroupResponse> response = groups.stream()
.map(g -> new GroupResponse(
g.getId(),
g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(),
g.getEducationForm().getName(),
g.getDepartmentId(),
g.getCourse(),
g.getSpecialityCode()
))
.map(this::mapToResponse)
.toList();
logger.info("Получено {} групп", response.size());
return response;
@@ -62,7 +100,9 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -70,9 +110,13 @@ public class GroupController {
.body("Группы для указанной кафедры не найдены");
}
logger.info("Найдено {} групп для кафедры с ID - {}", groups.size(), departmentId);
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
.toList();
return ResponseEntity.ok(groups);
logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -81,71 +125,44 @@ public class GroupController {
}
@PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, course = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getCourse());
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
try {
if (request.getName() == null || request.getName().isBlank()) {
String errorMessage = "Название группы обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
String errorMessage = "Группа с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupSize() == null) {
String errorMessage = "Численность группы обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getEducationFormId() == null) {
String errorMessage = "Форма обучения обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getCourse() == null || request.getCourse() == 0) {
String errorMessage = "Курс обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
ResponseEntity<?> validationError = validateGroupRequest(request);
if (validationError != null) {
return validationError;
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElse(null);
if (speciality == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
}
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
.orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
}
StudentGroup group = new StudentGroup();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setCourse(request.getCourse());
group.setSpecialityCode(request.getSpecialityCode());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(new GroupResponse(
group.getId(),
group.getName(),
group.getGroupSize(),
group.getEducationForm().getId(),
group.getEducationForm().getName(),
group.getDepartmentId(),
group.getCourse(),
group.getSpecialityCode()));
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -153,15 +170,260 @@ public class GroupController {
}
}
@PutMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
try {
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
if (groupOpt.isEmpty()) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
ResponseEntity<?> validationError = validateGroupRequest(request);
if (validationError != null) {
return validationError;
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElse(null);
if (speciality == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
}
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
.orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
}
StudentGroup group = groupOpt.get();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e) {
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id);
if (!groupRepository.existsById(id)) {
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
logger.info("Группа с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
group.archive("Группа архивирована");
groupRepository.save(group);
logger.info("Группа с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
}
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreGroup(@PathVariable Long id) {
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
group.restore();
groupRepository.save(group);
return ResponseEntity.ok(mapToResponse(group));
}
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
return validationError("Название группы обязательно");
}
if (request.getGroupSize() == null) {
return validationError("Численность группы обязательна");
}
if (request.getEducationFormId() == null) {
return validationError("Форма обучения обязательна");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
return validationError("ID кафедры обязателен");
}
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
return validationError("Год начала обучения обязателен");
}
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
return validationError("Код специальности обязателен");
}
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
return validationError("Профиль обучения обязателен");
}
return null;
}
private ResponseEntity<?> validationError(String errorMessage) {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
private GroupResponse mapToResponse(StudentGroup group) {
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile();
LocalDate today = LocalDate.now();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse(
group.getId(),
group.getName(),
group.getGroupSize(),
group.getEducationForm().getId(),
group.getEducationForm().getName(),
group.getDepartmentId(),
group.getYearStartStudy(),
course,
semester,
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getId(),
profile.getName(),
group.getStatus(),
groupLifecycleService.isAvailableForSelection(group, today),
studyState.name(),
studyState.getLabel()
);
}
@GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
return ResponseEntity.ok(assignments.stream()
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
.toList());
}
@PutMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
}
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
if (academicYear == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
if (calendar == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
}
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
}
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
}
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
}
StudentGroupCalendarAssignment assignment = assignmentRepository
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
.orElseGet(StudentGroupCalendarAssignment::new);
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache();
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}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {
return ResponseEntity.notFound().build();
}
assignmentRepository.delete(assignment);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
}
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(
assignment.getId(),
assignment.getStudentGroup().getId(),
assignment.getStudentGroup().getName(),
assignment.getAcademicYear().getId(),
assignment.getAcademicYear().getTitle(),
assignment.getAcademicCalendar().getId(),
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

@@ -0,0 +1,33 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.LessonTypeResponse;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.Role;
import com.magistr.app.repository.LessonTypesRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Comparator;
import java.util.List;
@RestController
@RequestMapping("/api/lesson-types")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class LessonTypeController {
private final LessonTypesRepository lessonTypesRepository;
public LessonTypeController(LessonTypesRepository lessonTypesRepository) {
this.lessonTypesRepository = lessonTypesRepository;
}
@GetMapping
public List<LessonTypeResponse> getAllLessonTypes() {
return lessonTypesRepository.findAll().stream()
.sorted(Comparator.comparing(LessonType::getLessonType))
.map(lessonType -> new LessonTypeResponse(lessonType.getId(), lessonType.getLessonType()))
.toList();
}
}

View File

@@ -1,507 +0,0 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateLessonRequest;
import com.magistr.app.dto.LessonResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.utils.DayAndWeekValidator;
import com.magistr.app.utils.TypeAndFormatLessonValidator;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import org.slf4j.Logger;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/users/lessons")
public class LessonsController {
private static final Logger logger = LoggerFactory.getLogger(LessonsController.class);
private final LessonRepository lessonRepository;
private final UserRepository teacherRepository;
private final GroupRepository groupRepository;
private final SubjectRepository subjectRepository;
private final EducationFormRepository educationFormRepository;
private final ClassroomRepository classroomRepository;
public LessonsController(LessonRepository lessonRepository, UserRepository teacherRepository, GroupRepository groupRepository, SubjectRepository subjectRepository, EducationFormRepository educationForm, ClassroomRepository classroomRepository) {
this.lessonRepository = lessonRepository;
this.teacherRepository = teacherRepository;
this.groupRepository = groupRepository;
this.subjectRepository = subjectRepository;
this.educationFormRepository = educationForm;
this.classroomRepository = classroomRepository;
}
//Создание нового занятия
@PostMapping("/create")
public ResponseEntity<?> createLesson(@RequestBody CreateLessonRequest request) {
//Полное логирование входящего запроса
logger.info("Получен запрос на создание занятия: teacherId={}, groupId={}, subjectId={}, day={}, week={}, time={}",
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getDay(), request.getWeek(), request.getTime());
//Проверка teacherId
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
String errorMessage = "ID преподавателя обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка groupId
if (request.getGroupId() == null || request.getGroupId() == 0) {
String errorMessage = "ID группы обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка subjectId
if (request.getSubjectId() == null || request.getSubjectId() == 0) {
String errorMessage = "ID предмета обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка lessonFormat
if (request.getLessonFormat() == null || request.getLessonFormat().isBlank()) {
String errorMessage = "Выбор формата занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())){
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
logger.info("Ошибка валидации формата: '{}' - {}", request.getLessonFormat(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка typeLesson
if (request.getTypeLesson() == null || request.getTypeLesson().isBlank()) {
String errorMessage = "Выбор типа занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())){
String errorMessage = "Некорректный тип занятия. " + TypeAndFormatLessonValidator.getValidTypesMessage();
logger.info("Ошибка валидации типа: '{}' - {}", request.getTypeLesson(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка classroomId
if (request.getClassroomId() == null || request.getClassroomId() == 0) {
String errorMessage = "ID аудитории обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка day
if (request.getDay() == null || request.getDay().isBlank()) {
String errorMessage = "Выбор дня обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!DayAndWeekValidator.isValidDay(request.getDay())){
String errorMessage = "Некорректный день недели. " + DayAndWeekValidator.getValidDaysMessage();
logger.info("Ошибка валидации дня: '{}' - {}", request.getDay(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка week
if (request.getWeek() == null || request.getWeek().isBlank()) {
String errorMessage = "Выбор недели обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!DayAndWeekValidator.isValidWeek(request.getWeek())){
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
logger.info("Ошибка валидации недели: '{}' - {}", request.getWeek(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка time
if (request.getTime() == null || request.getTime().isBlank()) {
String errorMessage = "Время обязательно";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Сохранение полученных данных и формирование ответа клиенту
try {
Lesson lesson = new Lesson();
lesson.setTeacherId(request.getTeacherId());
lesson.setSubjectId(request.getSubjectId());
lesson.setGroupId(request.getGroupId());
lesson.setLessonFormat(request.getLessonFormat());
lesson.setTypeLesson(request.getTypeLesson());
lesson.setClassroomId(request.getClassroomId());
lesson.setDay(request.getDay());
lesson.setWeek(request.getWeek());
lesson.setTime(request.getTime());
Lesson savedLesson = lessonRepository.save(lesson);
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", savedLesson.getId());
response.put("teacherId", savedLesson.getTeacherId());
response.put("groupId", savedLesson.getGroupId());
response.put("subjectId", savedLesson.getSubjectId());
response.put("formatLesson", savedLesson.getLessonFormat());
response.put("typeLesson", savedLesson.getTypeLesson());
response.put("classroomId", savedLesson.getClassroomId());
response.put("day", savedLesson.getDay());
response.put("week", savedLesson.getWeek());
response.put("time", savedLesson.getTime());
logger.info("Занятие успешно создано с ID: {}", savedLesson.getId());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при сохранении занятия: {}", e.getMessage(),e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании занятия: " + e.getMessage()));
}
}
//Запрос для получения всего списка занятий
@GetMapping
public List<LessonResponse> getAllLessons() {
logger.info("Запрос на получение всех занятий");
try {
List<Lesson> lessons = lessonRepository.findAll();
List<LessonResponse> response = lessons.stream()
.map(lesson -> {
String teacherName = teacherRepository.findById(lesson.getTeacherId())
.map(User::getUsername)
.orElse("Неизвестно");
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
String groupName = groupRepository.findById(lesson.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
String educationFormName = "Неизвестно";
if(group != null && group.getEducationForm() != null) {
Long educationFormId = group.getEducationForm().getId();
educationFormName = educationFormRepository.findById(educationFormId)
.map(EducationForm::getName)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(lesson.getSubjectId())
.map(Subject::getName)
.orElse("Неизвестно");
String classroomName = classroomRepository.findById(lesson.getClassroomId())
.map(Classroom::getName)
.orElse("Неизвестно");
return new LessonResponse(
lesson.getId(),
teacherName,
groupName,
classroomName,
educationFormName,
subjectName,
lesson.getTypeLesson(),
lesson.getLessonFormat(),
lesson.getDay(),
lesson.getWeek(),
lesson.getTime()
);
})
.toList();
logger.info("Получено {} занятий", lessons.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка всех занятий: {}", e.getMessage(), e);
throw e;
}
}
//Запрос на получение всех занятий для конкретного преподавателя
@GetMapping("/{teacherId}")
public ResponseEntity<?> getLessonsById(@PathVariable Long teacherId) {
logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId);
try {
List<Lesson> lessons = lessonRepository.findByTeacherId(teacherId);
if(lessons.isEmpty()) {
logger.info("У преподавателя с ID {} нет занятий", teacherId);
return ResponseEntity.ok(Map.of(
"message", "У преподавателя с ID " + teacherId +" нет занятий.",
"lessons", Collections.emptyList()
));
}
List<LessonResponse> lessonResponses = lessons.stream()
.map(lesson -> {
String teacherName = teacherRepository.findById(lesson.getTeacherId())
.map(User::getUsername)
.orElse("Неизвестно");
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
String groupName = groupRepository.findById(lesson.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
String educationFormName = "Неизвестно";
if(group != null && group.getEducationForm() != null) {
Long educationFormId = group.getEducationForm().getId();
educationFormName = educationFormRepository.findById(educationFormId)
.map(EducationForm::getName)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(lesson.getSubjectId())
.map(Subject::getName)
.orElse("Неизвестно");
String classroomName = classroomRepository.findById(lesson.getClassroomId())
.map(Classroom::getName)
.orElse("Неизвестно");
return new LessonResponse(
lesson.getId(),
teacherName,
groupName,
classroomName,
educationFormName,
subjectName,
lesson.getTypeLesson(),
lesson.getLessonFormat(),
lesson.getDay(),
lesson.getWeek(),
lesson.getTime()
);
})
.toList();
logger.info("Найдено {} занятий для преподавателя с ID: {}", lessonResponses.size(), teacherId);
return ResponseEntity.ok(lessonResponses);
} catch (Exception e ){
logger.error("Ошибка при получении занятий для преподавателя с ID {}: {}", teacherId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Ошибка при поиске занятий: " + e.getMessage()));
}
}
//Тестовый запрос на проверку доступности контроллера
@GetMapping("/ping")
public String ping() {
logger.debug("Получен ping запрос");
String response = "pong";
logger.debug("Ответ на ping: {}", response);
return response;
}
//Удаление занятия по его ID
@DeleteMapping("/delete/{lessonId}")
public ResponseEntity<?> deleteLessonById(@PathVariable Long lessonId){
logger.info("Запрос на удаление занятия по ID: {}", lessonId);
if(!lessonRepository.existsById(lessonId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Занятие не найдено"));
}
lessonRepository.deleteById(lessonId);
logger.info("Занятие с ID - {} успешно удалено", lessonId);
return ResponseEntity.ok(Map.of("message", "Занятие успешно удалено"));
}
//Обновление занятия по его ID
@PutMapping("/update/{lessonId}")
public ResponseEntity<?> updateLessonById(@PathVariable Long lessonId, @RequestBody CreateLessonRequest request) {
logger.info("Получен запрос на обновление занятия с ID - {}", lessonId);
logger.info("Данные для обновления: teacherId={}, groupId={}, subjectId={}, lessonFormat={}, typeLesson={}, classroomId={}, day={}, week={}, time={}",
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getLessonFormat(), request.getTypeLesson(), request.getClassroomId(),
request.getDay(), request.getWeek(), request.getTime());
try {
//Проверка на наличие записи
Lesson existingLesson = lessonRepository.findById(lessonId).orElse(null);
if(existingLesson == null) {
String errorMessage = "Занятие с ID " + lessonId + " не найдено";
logger.info("Ошибка: {}", errorMessage);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", errorMessage));
}
boolean hasChanges = false;
Map<String, Object> changes = new LinkedHashMap<>();
//Проверка и обновление teacherId, если он передан и отличается
if(request.getTeacherId() != null) {
if(!request.getTeacherId().equals(existingLesson.getTeacherId())) {
if(request.getTeacherId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID преподавателя не может быть равен 0"));
}
existingLesson.setTeacherId(request.getTeacherId());
changes.put("teacherId", request.getTeacherId());
hasChanges = true;
}
}
//Проверка и обновление groupId, если он передан и отличается
if(request.getGroupId() != null) {
if(!request.getGroupId().equals(existingLesson.getGroupId())) {
if(request.getGroupId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID группы не может быть равен 0"));
}
existingLesson.setGroupId(request.getGroupId());
changes.put("groupId", request.getGroupId());
hasChanges = true;
}
}
//Проверка и обновление subjectId, если он передан и отличается
if(request.getSubjectId() != null) {
if(!request.getSubjectId().equals(existingLesson.getSubjectId())) {
if(request.getSubjectId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID дисциплины не может быть равен 0"));
}
existingLesson.setSubjectId(request.getSubjectId());
changes.put("subjectId", request.getSubjectId());
hasChanges = true;
}
}
//Проверка и обновление lessonFormat, если он передан и отличается
if(request.getLessonFormat() != null) {
if(!request.getLessonFormat().equals(existingLesson.getLessonFormat())) {
if(request.getLessonFormat().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Формат занятия не может быть пустым"));
}
if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())) {
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
existingLesson.setLessonFormat(request.getLessonFormat());
changes.put("lessonFormat", request.getLessonFormat());
hasChanges = true;
}
}
//Проверка и обновление typeLesson, если он передан и отличается
if(request.getTypeLesson() != null) {
if(!request.getTypeLesson().equals(existingLesson.getTypeLesson())) {
if(request.getTypeLesson().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Тип занятия не может быть пустым"));
}
if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())) {
String errorMessage = "Некорректный тип занятий. " + TypeAndFormatLessonValidator.getValidTypesMessage();
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
existingLesson.setLessonFormat(request.getTypeLesson());
changes.put("typeLesson", request.getTypeLesson());
hasChanges = true;
}
}
//Проверка и обновление classroomId, если он передан и отличается
if(request.getClassroomId() != null) {
if(!request.getClassroomId().equals(existingLesson.getClassroomId())) {
if(request.getClassroomId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID аудитории не можеть быть равен 0"));
}
existingLesson.setClassroomId(request.getClassroomId());
changes.put("classroomId", request.getClassroomId());
hasChanges = true;
}
}
//Проверка и обновление day, если он передан и отличается
if(request.getDay() != null){
if(!request.getDay().equals(existingLesson.getDay())) {
if(request.getDay().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"День\" не может быть пустым"));
}
if(!DayAndWeekValidator.isValidDay(request.getDay())) {
String errorMessage = "Некорректный день. " + DayAndWeekValidator.getValidDaysMessage();
return ResponseEntity.badRequest()
.body(Map.of("message", errorMessage));
}
existingLesson.setDay(request.getDay());
changes.put("day", request.getDay());
hasChanges = true;
}
}
//Проверка и обновление week, если он передан и отличается
if(request.getWeek() != null) {
if(!request.getWeek().equals(existingLesson.getWeek())) {
if (request.getWeek().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"Неделя\" не может быть пустым"));
}
if (!DayAndWeekValidator.isValidWeek(request.getWeek())) {
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
return ResponseEntity.badRequest()
.body((Map.of("message", errorMessage)));
}
existingLesson.setWeek(request.getWeek());
changes.put("week", request.getWeek());
hasChanges = true;
}
}
//Проверка и обновление time, если он передан и отличается
if(request.getTime() != null) {
if(!request.getTime().equals(existingLesson.getTime())) {
if(request.getTime().isBlank()){
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"Время\" не может быть пустым"));
}
existingLesson.setTime(request.getTime());
changes.put("time", request.getTime());
hasChanges = true;
}
}
if(!hasChanges) {
logger.info("Обновление не требуется - все полня идентичны существующим для занятия с ID: {}", lessonId);
Map<String, Object> response = buildResponse(existingLesson);
response.put("message", "Изменений не обнаружено");
return ResponseEntity.ok(response);
}
Lesson updatedLesson = lessonRepository.save(existingLesson);
Map<String, Object> response = buildResponse(updatedLesson);
response.put("updatedFields", changes);
response.put("message", "Занятие успешно обновлено");
logger.info("Занятие с ID - {} успешно обновлено. Изменения: {}", lessonId, changes);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при обновлении занятия с ID {}: {}", lessonId, e.getMessage(),e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении занятия: " + e.getMessage()));
}
}
private Map<String, Object> buildResponse(Lesson lesson) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", lesson.getId());
response.put("teacherId", lesson.getTeacherId());
response.put("groupId", lesson.getGroupId());
response.put("subjectId", lesson.getSubjectId());
response.put("LessonFormat", lesson.getLessonFormat());
response.put("typeLesson", lesson.getTypeLesson());
response.put("classroomId", lesson.getClassroomId());
response.put("day", lesson.getDay());
response.put("week", lesson.getWeek());
response.put("time", lesson.getTime());
return response;
}
}

View File

@@ -0,0 +1,57 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Map;
@RestController
@RequestMapping("/api/schedule")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleQueryService scheduleQueryService;
public ScheduleController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long groupId,
@RequestParam(required = false) Long teacherId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}",
groupId, teacherId, startDate, endDate);
if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
}
return ResponseEntity.ok(scheduleQueryService.search(
groupId,
teacherId,
null,
null,
null,
null,
null,
null,
startDate,
endDate
));
}
}

View File

@@ -1,288 +0,0 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateScheduleDataRequest;
import com.magistr.app.dto.ScheduleResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.utils.SemesterTypeValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/department/schedule")
public class ScheduleDataController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class);
private final ScheduleDataRepository scheduleDataRepository;
private final GroupRepository groupRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SubjectRepository subjectRepository;
private final LessonTypesRepository lessonTypesRepository;
private final UserRepository userRepository;
public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) {
this.scheduleDataRepository = scheduleDataRepository;
this.groupRepository = groupRepository;
this.specialtiesRepository = specialtiesRepository;
this.subjectRepository = subjectRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.userRepository = userRepository;
}
@GetMapping("/allList")
public List<ScheduleData> getAllScheduleDataList() {
logger.info("Получен запрос на получение списка данных расписаний");
try {
List<ScheduleData> scheduleData = scheduleDataRepository.findAll();
List<ScheduleData> response = scheduleData.stream()
.map(s -> new ScheduleData(
s.getId(),
s.getDepartmentId(),
s.getSemester(),
s.getGroupId(),
s.getSubjectsId(),
s.getLessonTypeId(),
s.getNumberOfHours(),
s.getDivision(),
s.getTeacherId(),
s.getSemesterType(),
s.getPeriod()
))
.toList();
logger.info("Получено {} записей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping
public ResponseEntity<?> getSingleScheduleData(
@RequestParam Long departmentId,
@RequestParam SemesterType semesterType,
@RequestParam String period
) {
logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}",
departmentId, semesterType, period);
try {
List<ScheduleData> scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period );
if(scheduleData.isEmpty()){
logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period);
return ResponseEntity.ok(Map.of(
"message", "Записей не найдено"
));
}
List<ScheduleResponse> response = scheduleData.stream()
.map( s -> {
String groupName = groupRepository.findById(s.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
Integer groupCourse = groupRepository.findById(s.getGroupId())
.map(StudentGroup::getCourse)
.orElse(null);
String specialityCode = "Неизвестно";
StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null);
if (group != null) {
Long specialityId = group.getSpecialityCode();
specialityCode = specialtiesRepository.findById(specialityId).
map(Speciality::getSpecialityCode)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(s.getSubjectsId())
.map(Subject::getName)
.orElse("Неизвестно");
String lessonType = lessonTypesRepository.findById(s.getLessonTypeId())
.map(LessonType::getLessonType)
.orElse("Неизвестно");
String teacherName = userRepository.findById(s.getTeacherId())
.map(User::getFullName)
.orElse("Неизвестно");
String teacherjobTitle = userRepository.findById(s.getTeacherId())
.map(User::getJobTitle)
.orElse("Неизвестно");
return new ScheduleResponse(
s.getId(),
s.getDepartmentId(),
specialityCode,
s.getSemester(),
groupName,
groupCourse,
subjectName,
lessonType,
s.getNumberOfHours(),
s.getDivision(),
teacherName,
teacherjobTitle,
s.getSemesterType(),
s.getPeriod());
}
)
.toList();
logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e);
throw e;
}
}
// Доделать проверки получаемых полей!!!
@PostMapping("/create")
public ResponseEntity<?> createScheduleData(@RequestBody CreateScheduleDataRequest request) {
logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, semester={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}",
request.getDepartmentId(), request.getSemester(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod());
try {
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!scheduleDataRepository.existsById(request.getDepartmentId())) {
String errorMessage = "Кафедра не найдена";
logger.info("Кафедра не найдена");
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSemester() == null || request.getSemester() == 0) {
String errorMessage = "Семестр обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(request.getSemester() > 12) {
String errorMessage = "Семестр должен быть меньше или равен 12";
logger.info("Семестр должен быть меньше или равен 12");
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupId() == null || request.getGroupId() == 0) {
String errorMessage = "ID группы обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSubjectsId() == null || request.getSubjectsId() == 0) {
String errorMessage = "ID дисциплины обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) {
String errorMessage = "ID типа занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getNumberOfHours() == null) {
request.setNumberOfHours(0L);
}
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
String errorMessage = "ID преподавателя обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSemesterType() == null) {
String errorMessage = "Семестр обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) {
String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes();
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPeriod() == null || request.getPeriod().isBlank()) {
String errorMessage = "Период обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndSemesterAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
request.getDepartmentId(),
request.getSemester(),
request.getGroupId(),
request.getSubjectsId(),
request.getLessonTypeId(),
request.getNumberOfHours(),
request.getDivision(),
request.getTeacherId(),
request.getSemesterType(),
request.getPeriod()
);
if(existsRecord) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
}
ScheduleData scheduleData = new ScheduleData();
scheduleData.setDepartmentId(request.getDepartmentId());
scheduleData.setSemester(request.getSemester());
scheduleData.setGroupId(request.getGroupId());
scheduleData.setSubjectsId(request.getSubjectsId());
scheduleData.setLessonTypeId(request.getLessonTypeId());
scheduleData.setNumberOfHours(request.getNumberOfHours());
scheduleData.setDivision(request.getDivision());
scheduleData.setTeacherId(request.getTeacherId());
scheduleData.setSemesterType(request.getSemesterType());
scheduleData.setPeriod(request.getPeriod());
ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData);
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", savedSchedule.getId());
response.put("departmentId", savedSchedule.getDepartmentId());
response.put("semester", savedSchedule.getSemester());
response.put("groupId", savedSchedule.getGroupId());
response.put("subjectId", savedSchedule.getSubjectsId());
response.put("lessonTypeId", savedSchedule.getLessonTypeId());
response.put("numberOfHours", savedSchedule.getNumberOfHours());
response.put("isDivision", savedSchedule.getDivision());
response.put("teacherId", savedSchedule.getTeacherId());
response.put("semesterType", savedSchedule.getSemesterType());
response.put("period", savedSchedule.getPeriod());
logger.info("Запись успешно создана с ID: {}", savedSchedule.getId());
return ResponseEntity.ok(response);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
} catch (Exception e) {
logger.error("Ошибка при создании записи: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteById(@PathVariable Long id) {
logger.info("Получен запрос на удаление записи с ID: {}", id);
if(!scheduleDataRepository.existsById(id)) {
logger.info("Запись с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
scheduleDataRepository.deleteById(id);
logger.info("Запись с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Запись удалена"));
}
}

View File

@@ -0,0 +1,148 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/edu-office/schedule/overrides")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleOverrideController {
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
return save(null, request);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return save(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
scheduleOverrideRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
}
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
ScheduleOverride override = id == null
? scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
.orElseGet(ScheduleOverride::new)
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
override.setLessonDate(request.lessonDate());
override.setAction(request.action());
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
override.setNewLessonFormat(clean(request.newLessonFormat()));
override.setComment(clean(request.comment()));
override.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.save(override);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
private String validate(ScheduleOverrideDto request) {
if (request == null) {
return "Передайте данные изменения расписания";
}
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
return "Базовый слот расписания не найден";
}
if (request.lessonDate() == null) {
return "Дата пары обязательна";
}
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
return "Недопустимое действие изменения расписания";
}
if (request.newClassroomId() != null) {
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
return "Активная аудитория не найдена";
}
}
if (request.newTeacherId() != null) {
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return "Активный преподаватель не найден";
}
}
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
return "Временной слот не найден";
}
return null;
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -0,0 +1,664 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*;
@RestController
@RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController {
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 SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
private final GroupRepository groupRepository;
private final TimeSlotRepository timeSlotRepository;
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
SemesterRepository semesterRepository,
GroupRepository groupRepository,
TimeSlotRepository timeSlotRepository,
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
this.semesterRepository = semesterRepository;
this.groupRepository = groupRepository;
this.timeSlotRepository = timeSlotRepository;
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
@RequestParam(required = false) Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
.map(this::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<?> getById(@PathVariable Long id) {
return scheduleRuleRepository.findByIdWithDetails(id)
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
@Transactional
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
try {
ScheduleRule rule = new ScheduleRule();
applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@PutMapping("/{id}")
@Transactional
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
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);
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private String validateRule(ScheduleRuleDto request) {
if (request.subjectId() == null) {
return "Дисциплина обязательна";
}
if (request.semesterId() == null) {
return "Семестр обязателен";
}
String hoursValidationError = validateTypeHours(request);
if (hoursValidationError != null) {
return hoursValidationError;
}
if (request.groupIds() == null || request.groupIds().isEmpty()) {
return "Нужно выбрать хотя бы одну группу";
}
if (request.slots() == null || request.slots().isEmpty()) {
return "Добавьте хотя бы один слот занятия";
}
return null;
}
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
rule.setSubject(subject);
rule.setSemester(semester);
rule.setLectureAcademicHours(request.lectureAcademicHours());
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
rule.setPracticeAcademicHours(request.practiceAcademicHours());
rule.setLectureStartWeek(request.lectureStartWeek());
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
rule.setPracticeStartWeek(request.practiceStartWeek());
rule.getGroups().clear();
rule.getGroups().addAll(groups);
List<ScheduleRuleSlot> slots = new ArrayList<>();
for (ScheduleRuleSlotDto slotDto : request.slots()) {
slots.add(buildSlot(rule, slotDto));
}
validateTypeCoverage(rule, slots);
rule.getSlots().clear();
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) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat(slotDto.lessonFormat().trim());
return slot;
}
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
}
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
}
return subgroups;
}
private String validateTypeHours(ScheduleRuleDto request) {
if (isMissing(request.lectureAcademicHours())
|| isMissing(request.laboratoryAcademicHours())
|| isMissing(request.practiceAcademicHours())) {
return "Укажите количество часов для всех типов занятий";
}
if (isNegative(request.lectureAcademicHours())
|| isNegative(request.laboratoryAcademicHours())
|| isNegative(request.practiceAcademicHours())) {
return "Количество часов по типам занятий не может быть отрицательным";
}
if (isInvalidStartWeek(request.lectureStartWeek())
|| isInvalidStartWeek(request.laboratoryStartWeek())
|| isInvalidStartWeek(request.practiceStartWeek())) {
return "Неделя начала занятий должна быть больше нуля";
}
int totalHours = valueOrZero(request.lectureAcademicHours())
+ valueOrZero(request.laboratoryAcademicHours())
+ valueOrZero(request.practiceAcademicHours());
if (totalHours <= 0) {
return "Укажите часы хотя бы для одного типа занятий";
}
return null;
}
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
for (ScheduleRuleSlot slot : slots) {
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
}
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int hours = rule.academicHoursFor(category);
boolean hasSlots = categoriesWithSlots.contains(category);
if (hours > 0 && !hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
}
if (hours == 0 && hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
}
}
}
private boolean isMissing(Integer value) {
return value == null;
}
private boolean isNegative(Integer value) {
return value < 0;
}
private boolean isInvalidStartWeek(Integer value) {
return value == null || value <= 0;
}
private int valueOrZero(Integer value) {
return value == null ? 0 : value;
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
List<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
.thenComparing(ScheduleRuleSlot::getId))
.map(this::toSlotDto)
.toList();
return new ScheduleRuleDto(
rule.getId(),
rule.getSubject().getId(),
rule.getSubject().getName(),
rule.getSemester().getId(),
rule.getSemester().getAcademicYear().getTitle(),
rule.getSemester().getSemesterType(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
dayName(slot.getDayOfWeek()),
slot.getParity(),
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
slot.getClassroom().getName(),
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
return "Неизвестно";
}
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
}
private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
}
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
}

View File

@@ -0,0 +1,55 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/schedule/search")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleSearchController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class);
private final ScheduleQueryService scheduleQueryService;
public ScheduleSearchController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
public ResponseEntity<?> search(
@RequestParam(required = false) Long groupId,
@RequestParam(required = false) Long teacherId,
@RequestParam(required = false) Long classroomId,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long subjectId,
@RequestParam(required = false) Long lessonTypeId,
@RequestParam(required = false) Long timeSlotId,
@RequestParam(required = false) String parity,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
groupId, teacherId, classroomId, departmentId, startDate, endDate);
return ResponseEntity.ok(scheduleQueryService.search(
groupId,
teacherId,
classroomId,
departmentId,
subjectId,
lessonTypeId,
timeSlotId,
parity,
startDate,
endDate
));
}
}

View File

@@ -1,9 +1,15 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.dto.SpecialtyProfileDto;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -15,21 +21,27 @@ import java.util.Map;
@RestController
@RequestMapping("/api/specialties")
@RequireRoles({Role.ADMIN})
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
public SpecialityController(SpecialtiesRepository specialtiesRepository) {
public SpecialityController(SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository) {
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
}
@GetMapping
public List<Speciality> getAllSpecialties() {
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = specialtiesRepository.findAll();
List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
@@ -75,6 +87,10 @@ public class SpecialityController {
speciality.setSpecialityName(request.getSpecialityName());
speciality.setSpecialityCode(request.getSpecialityCode());
specialtiesRepository.save(speciality);
SpecialtyProfile defaultProfile = new SpecialtyProfile();
defaultProfile.setSpeciality(speciality);
defaultProfile.setName("Без профиля");
specialtyProfileRepository.save(defaultProfile);
logger.info("Специальность успешно создана с ID: {}", speciality.getId());
@@ -92,15 +108,192 @@ public class SpecialityController {
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(id)) {
@PutMapping("/{id}")
public ResponseEntity<?> updateSpeciality(@PathVariable Long id, @RequestBody CreateSpecialityRequest request) {
logger.info("Получен запрос на обновление специальности с ID: {}", id);
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
specialtiesRepository.deleteById(id);
logger.info("Специальность с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Специальнсть удалена"));
try {
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
String errorMessage = "Название специальности обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
String specialityName = request.getSpecialityName().trim();
String specialityCode = request.getSpecialityCode().trim();
if (specialtiesRepository.findBySpecialityName(specialityName)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Специальность с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityCode(specialityCode)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Специальность с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
speciality.setSpecialityName(specialityName);
speciality.setSpecialityCode(specialityCode);
specialtiesRepository.save(speciality);
logger.info("Специальность с ID - {} успешно обновлена", id);
return ResponseEntity.ok(new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
speciality.archive("Специальность архивирована");
specialtiesRepository.save(speciality);
logger.info("Специальность с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Специальность архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreSpeciality(@PathVariable Long id) {
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
return ResponseEntity.notFound().build();
}
speciality.restore();
specialtiesRepository.save(speciality);
return ResponseEntity.ok(new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
));
}
@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")
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream()
.map(this::toProfileDto)
.toList());
}
@PostMapping("/{specialtyId}/profiles")
public ResponseEntity<?> createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) {
Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null);
if (speciality == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateProfile(specialtyId, null, request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
SpecialtyProfile profile = new SpecialtyProfile();
profile.setSpeciality(speciality);
profile.setName(request.name().trim());
profile.setDescription(trimToNull(request.description()));
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
}
@PutMapping("/{specialtyId}/profiles/{profileId}")
public ResponseEntity<?> updateProfile(@PathVariable Long specialtyId,
@PathVariable Long profileId,
@RequestBody SpecialtyProfileDto request) {
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
String validationError = validateProfile(specialtyId, profileId, request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
profile.setName(request.name().trim());
profile.setDescription(trimToNull(request.description()));
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
}
@DeleteMapping("/{specialtyId}/profiles/{profileId}")
public ResponseEntity<?> deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) {
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
try {
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
} catch (Exception e) {
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
}
}
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
if (request == null) {
return "Передайте данные профиля";
}
if (request.name() == null || request.name().isBlank()) {
return "Название профиля обязательно";
}
return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim())
.filter(existing -> !existing.getId().equals(profileId))
.map(existing -> "Профиль с таким названием уже существует")
.orElse(null);
}
private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) {
Speciality speciality = profile.getSpeciality();
return new SpecialtyProfileDto(
profile.getId(),
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getName(),
profile.getDescription()
);
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,189 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.SubgroupDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
private final SubgroupRepository subgroupRepository;
private final GroupRepository groupRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public SubgroupController(SubgroupRepository subgroupRepository,
GroupRepository groupRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.subgroupRepository = subgroupRepository;
this.groupRepository = groupRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/api/groups/{groupId}/subgroups")
public ResponseEntity<?> getByGroup(@PathVariable Long groupId) {
if (!groupRepository.existsById(groupId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream()
.map(this::toDto)
.toList());
}
@PostMapping("/api/groups/{groupId}/subgroups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findById(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
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.setStudentGroup(group);
subgroup.setName(request.name().trim());
subgroup.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
String newName = request.name().trim();
boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName)
&& !subgroup.getName().equalsIgnoreCase(newName);
if (duplicateName) {
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.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try {
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
}
private String validate(SubgroupDto request) {
if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно";
}
if (request.studentCapacity() == null) {
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;
}
private SubgroupDto toDto(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
return new SubgroupDto(
subgroup.getId(),
group.getId(),
group.getName(),
subgroup.getName(),
subgroup.getStudentCapacity()
);
}
}

View File

@@ -1,7 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
@@ -16,6 +19,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
@RequireRoles({Role.ADMIN})
public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
@@ -27,10 +31,13 @@ public class SubjectController {
}
@GetMapping
public List<Subject> getAllSubjects() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = subjectRepository.findAll();
List<Subject> subjects = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> response = subjects.stream()
.map(s -> new Subject(
s.getId(),
@@ -48,10 +55,11 @@ public class SubjectController {
}
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
@@ -121,12 +129,31 @@ public class SubjectController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
if (!subjectRepository.existsById(id)) {
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
logger.info("Дисциплина с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
subject.archive("Дисциплина архивирована");
subjectRepository.save(subject);
logger.info("Дисциплина с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована"));
}
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreSubject(@PathVariable Long id) {
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
return ResponseEntity.notFound().build();
}
subject.restore();
subjectRepository.save(subject);
return ResponseEntity.ok(new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
));
}
}

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

@@ -1,8 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherSubjectResponse;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
@@ -14,6 +19,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
if (!userRepository.existsById(userId)) {
User user = userRepository.findById(userId).orElse(null);
if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (!subjectRepository.existsById(subjectId)) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || subject.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
}
if (!canManage(user, subject)) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) {
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) {
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
if (teacherSubject == null) {
return ResponseEntity.notFound().build();
}
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
}
teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
}
private boolean canManage(User teacher, Subject subject) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null || currentUser.role() == Role.ADMIN) {
return true;
}
if (currentUser.role() != Role.DEPARTMENT) {
return false;
}
Long departmentId = currentUser.departmentId();
return departmentId != null
&& teacher != null
&& subject != null
&& departmentId.equals(teacher.getDepartmentId())
&& departmentId.equals(subject.getDepartmentId());
}
}

View File

@@ -0,0 +1,306 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.dto.TimeSlotScopeDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/time-slots")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class TimeSlotAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private static final String APPLY_MODE_MANUAL = "MANUAL";
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
}
@GetMapping
public List<TimeSlotDto> getAll() {
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/effective")
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
}
@GetMapping("/scopes")
public List<TimeSlotScopeDto> getScopes() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(this::toScopeDto)
.toList();
}
@PostMapping("/scopes")
public ResponseEntity<?> createScope(@RequestBody TimeSlotScopeDto request) {
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
TimeSlotScope scope = new TimeSlotScope();
scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", ""));
scope.setName(request.name().trim());
scope.setApplyMode(APPLY_MODE_MANUAL);
scope.setDayOfWeek(null);
scope.setSystemScope(false);
scope.setDisplayOrder(nextManualDisplayOrder());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@PutMapping("/scopes/{id}")
public ResponseEntity<?> updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять"));
}
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
scope.setName(request.name().trim());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@DeleteMapping("/scopes/{id}")
public ResponseEntity<?> deleteScope(@PathVariable Long id) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить"));
}
timeSlotScopeRepository.delete(scope);
return ResponseEntity.ok(Map.of("message", "Сетка времени удалена"));
}
@GetMapping("/date-assignments")
public List<TimeSlotDateAssignmentDto> getDateAssignments(@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate) {
List<TimeSlotDateAssignment> assignments = startDate != null && endDate != null
? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate)
: dateAssignmentRepository.findAllByOrderByDateAsc();
return assignments.stream().map(this::toAssignmentDto).toList();
}
@PostMapping("/date-assignments")
public ResponseEntity<?> saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) {
if (request.date() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна"));
}
if (request.scopeId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна"));
}
TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null);
if (scope == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена"));
}
if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) {
return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки"));
}
TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date())
.orElseGet(TimeSlotDateAssignment::new);
assignment.setDate(request.date());
assignment.setTimeSlotScope(scope);
return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment)));
}
@DeleteMapping("/date-assignments/{id}")
public ResponseEntity<?> deleteDateAssignment(@PathVariable Long id) {
if (!dateAssignmentRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
dateAssignmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено"));
}
@PostMapping
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, null)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
TimeSlot timeSlot = new TimeSlot();
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
if (timeSlot == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, id)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!timeSlotRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
}
}
private String validate(TimeSlotDto request) {
if (request.orderNumber() == null || request.orderNumber() <= 0) {
return "Номер пары должен быть больше нуля";
}
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
return "Выберите сетку времени";
}
if (request.startTime() == null || request.endTime() == null) {
return "Время начала и окончания обязательно";
}
if (!request.startTime().isBefore(request.endTime())) {
return "Время начала должно быть раньше времени окончания";
}
return null;
}
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
? request.durationMinutes()
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
timeSlot.setDurationMinutes(duration);
}
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
.isPresent();
}
private List<TimeSlot> effectiveSlots(LocalDate date) {
TimeSlotScope defaultScope = defaultScope();
TimeSlotScope effectiveScope = effectiveScope(date);
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
if (effectiveScope.getId().equals(defaultScope.getId())) {
return defaultSlots;
}
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
return new ArrayList<>(slotsByOrder.values());
}
private TimeSlotScope effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()))
.orElseGet(this::defaultScope);
}
private TimeSlotScope defaultScope() {
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
}
private int nextManualDisplayOrder() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(TimeSlotScope::getDisplayOrder)
.filter(value -> value != null)
.max(Integer::compareTo)
.orElse(100) + 10;
}
private TimeSlotDto toDto(TimeSlot timeSlot) {
TimeSlotScope scope = timeSlot.getTimeSlotScope();
return new TimeSlotDto(
timeSlot.getId(),
timeSlot.getOrderNumber(),
scope == null ? null : scope.getId(),
scope == null ? null : scope.getName(),
scope == null ? null : scope.getApplyMode(),
scope == null ? null : scope.getDayOfWeek(),
timeSlot.getStartTime(),
timeSlot.getEndTime(),
timeSlot.getDurationMinutes()
);
}
private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) {
return new TimeSlotScopeDto(
scope.getId(),
scope.getCode(),
scope.getName(),
scope.getApplyMode(),
scope.getDayOfWeek(),
scope.getSystemScope(),
scope.getDisplayOrder()
);
}
private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) {
TimeSlotScope scope = assignment.getTimeSlotScope();
return new TimeSlotDateAssignmentDto(
assignment.getId(),
assignment.getDate(),
scope.getId(),
scope.getName()
);
}
}

View File

@@ -1,203 +1,333 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
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.TeacherDepartmentAssignment;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
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.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@RequireRoles({Role.ADMIN})
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
}
@GetMapping
public List<UserResponse> getAllUsers() {
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
try {
List<User> users = userRepository.findAll();
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
throw e;
}
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream()
.map(this::toUserResponse)
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try {
List<User> users = userRepository.findByRole(Role.TEACHER);
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
throw e;
}
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
try {
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
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("Произошла ошибка при получении списка преподавателей");
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
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
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());
try {
if (request.getUsername() == null || request.getUsername().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", 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);
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.getUsername() == null || request.getUsername().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
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}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
if (!userRepository.existsById(id)) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
userRepository.deleteById(id);
logger.info("Пользователь с ID - {} успешно удалён", id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
user.archive("Пользователь архивирован");
userRepository.save(user);
logger.info("Пользователь с ID - {} успешно архивирован", id);
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
user.restore();
userRepository.save(user);
return ResponseEntity.ok(toUserResponse(user));
}
@GetMapping("/{id}/department-history")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
if (!userRepository.existsById(id)) {
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()
.map(this::toAssignmentDto)
.toList());
}
@PostMapping("/{id}/department-transfer")
@Transactional
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
User teacher = userRepository.findById(id).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
}
if (request.departmentId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
.ifPresent(current -> {
if (current.getValidFrom().isBefore(validFrom)) {
current.setValidTo(validFrom.minusDays(1));
teacherDepartmentAssignmentRepository.save(current);
} else {
teacherDepartmentAssignmentRepository.delete(current);
}
});
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setPrimaryAssignment(true);
assignment.setComment(request.comment());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
return ResponseEntity.ok(toAssignmentDto(assignment));
}
@GetMapping("/teachers/by-department/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false)
@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;
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.map(this::toUserResponse)
.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) {
return new TeacherDepartmentAssignmentDto(
assignment.getId(),
assignment.getTeacher().getId(),
assignment.getTeacher().getFullName(),
assignment.getDepartment().getId(),
assignment.getDepartment().getDepartmentName(),
assignment.getValidFrom(),
assignment.getValidTo(),
assignment.getPrimaryAssignment(),
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

@@ -0,0 +1,188 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.*;
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.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/workload")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class WorkloadController {
private static final int ACADEMIC_HOURS_PER_LESSON = 2;
private final ScheduleQueryService scheduleQueryService;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
public WorkloadController(ScheduleQueryService scheduleQueryService,
ClassroomRepository classroomRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
this.scheduleQueryService = scheduleQueryService;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
}
@GetMapping("/teachers")
public List<WorkloadSummaryDto> teacherWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
teacherId -> {
User teacher = teachersById.get(teacherId);
return teacher == null ? null : teacher.getDepartmentId();
});
}
@GetMapping("/classrooms")
public List<WorkloadSummaryDto> classroomWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
}
@GetMapping("/departments")
public List<WorkloadSummaryDto> departmentWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
.collect(Collectors.groupingBy(lesson -> {
User teacher = teachersById.get(lesson.teacherId());
return teacher == null ? 0L : teacher.getDepartmentId();
}));
return byDepartment.entrySet().stream()
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
@GetMapping("/time-slots")
public List<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
return summarize(lessons, RenderedLessonDto::timeSlotId,
lesson -> lesson.timeSlotOrder() == null
? "Пара"
: lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")",
ignored -> null);
}
@GetMapping("/free-classrooms")
public List<ClassroomResponse> freeClassrooms(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
null, null, timeSlotId, null, date, date)
.stream()
.map(RenderedLessonDto::classroomId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
.map(this::toClassroomResponse)
.toList();
}
private List<WorkloadSummaryDto> summarize(List<RenderedLessonDto> lessons,
Function<RenderedLessonDto, Long> idFn,
Function<RenderedLessonDto, String> nameFn,
Function<Long, Long> departmentFn) {
Map<Long, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> idFn.apply(lesson) != null)
.collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream()
.map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List<RenderedLessonDto> lessons) {
return new WorkloadSummaryDto(
id,
name,
departmentId,
departmentName(departmentId),
lessons.size(),
lessons.size() * ACADEMIC_HOURS_PER_LESSON,
lessons.stream()
.map(lesson -> lesson.date() + ":" + lesson.timeSlotId())
.distinct()
.count()
);
}
private String departmentName(Long departmentId) {
if (departmentId == null) {
return null;
}
return departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Кафедра не найдена");
}
private ClassroomResponse toClassroomResponse(Classroom classroom) {
return new ClassroomResponse(
classroom.getId(),
classroom.getName(),
classroom.getCapacity(),
classroom.getBuilding(),
classroom.getFloor(),
classroom.getIsAvailable(),
classroom.getStatus(),
classroom.getArchivedAt(),
classroom.getArchiveReason(),
new ArrayList<>(classroom.getEquipments())
);
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarActivityTypeDto(
Long id,
String code,
String name,
Boolean allowSchedule,
String colorCode,
Integer displayOrder,
String description
) {
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record AcademicCalendarDto(
Long id,
String title,
Long academicYearId,
String academicYearTitle,
LocalDate academicYearStartDate,
LocalDate academicYearEndDate,
Long specialtyId,
String specialtyCode,
String specialtyName,
Long specialtyProfileId,
String specialtyProfileName,
Long studyFormId,
String studyFormName,
Integer courseCount
) {
}

View File

@@ -0,0 +1,17 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record AcademicCalendarGridDayDto(
Long id,
Long calendarId,
Integer courseNumber,
LocalDate date,
Integer weekNumber,
Integer dayOfWeek,
Long activityTypeId,
String activityCode,
String activityName,
Boolean allowSchedule
) {
}

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,13 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record AcademicYearDto(
Long id,
String title,
LocalDate startDate,
LocalDate endDate,
List<SemesterDto> semesters
) {
}

View File

@@ -5,6 +5,8 @@ import java.util.List;
public class ClassroomRequest {
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private List<Long> equipmentIds;
@@ -24,6 +26,22 @@ public class ClassroomRequest {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -1,23 +1,41 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.util.List;
public class ClassroomResponse {
private Long id;
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private String archiveReason;
private List<Equipment> equipments;
public ClassroomResponse() {
}
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> equipments) {
this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments);
}
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, String status, LocalDateTime archivedAt,
String archiveReason, List<Equipment> equipments) {
this.id = id;
this.name = name;
this.capacity = capacity;
this.building = building;
this.floor = floor;
this.isAvailable = isAvailable;
this.status = status;
this.archivedAt = archivedAt;
this.archiveReason = archiveReason;
this.equipments = equipments;
}
@@ -45,6 +63,22 @@ public class ClassroomResponse {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}
@@ -53,6 +87,30 @@ public class ClassroomResponse {
this.isAvailable = isAvailable;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
this.archivedAt = archivedAt;
}
public String getArchiveReason() {
return archiveReason;
}
public void setArchiveReason(String archiveReason) {
this.archiveReason = archiveReason;
}
public List<Equipment> getEquipments() {
return equipments;
}

View File

@@ -6,7 +6,9 @@ public class CreateGroupRequest {
private Long groupSize;
private Long educationFormId;
private Long departmentId;
private Integer course;
private Integer yearStartStudy;
private Long specialtyId;
private Long specialtyProfileId;
private Long specialityCode;
public String getName() {
@@ -41,12 +43,12 @@ public class CreateGroupRequest {
this.departmentId = departmentId;
}
public Integer getCourse() {
return course;
public Integer getYearStartStudy() {
return yearStartStudy;
}
public void setCourse(Integer course) {
this.course = course;
public void setYearStartStudy(Integer yearStartStudy) {
this.yearStartStudy = yearStartStudy;
}
public Long getSpecialityCode() {
@@ -56,4 +58,24 @@ public class CreateGroupRequest {
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
}
public Long getSpecialtyId() {
return specialtyId;
}
public void setSpecialtyId(Long specialtyId) {
this.specialtyId = specialtyId;
}
public Long getSpecialtyProfileId() {
return specialtyProfileId;
}
public void setSpecialtyProfileId(Long specialtyProfileId) {
this.specialtyProfileId = specialtyProfileId;
}
public Long getEffectiveSpecialtyId() {
return specialtyId != null ? specialtyId : specialityCode;
}
}

View File

@@ -1,89 +0,0 @@
package com.magistr.app.dto;
public class CreateLessonRequest {
private Long teacherId;
private Long groupId;
private Long subjectId;
private String lessonFormat;
private String typeLesson;
private Long classroomId;
private String day;
private String week;
private String time;
public CreateLessonRequest() {
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
public String getLessonFormat() {
return lessonFormat;
}
public void setLessonFormat(String lessonFormat) {
this.lessonFormat = lessonFormat;
}
public String getTypeLesson() {
return typeLesson;
}
public void setTypeLesson(String typeLesson) {
this.typeLesson = typeLesson;
}
public Long getClassroomId() {
return classroomId;
}
public void setClassroomId(Long classroomId) {
this.classroomId = classroomId;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

@@ -1,105 +0,0 @@
package com.magistr.app.dto;
import com.magistr.app.model.SemesterType;
public class CreateScheduleDataRequest {
private Long id;
private Long departmentId;
private Long semester;
private Long groupId;
private Long subjectsId;
private Long lessonTypeId;
private Long numberOfHours;
private Boolean division;
private Long teacherId;
private SemesterType semesterType;
private String period;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getSemester() {
return semester;
}
public void setSemester(Long semester) {
this.semester = semester;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectsId() {
return subjectsId;
}
public void setSubjectsId(Long subjectsId) {
this.subjectsId = subjectsId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public void setNumberOfHours(Long numberOfHours) {
this.numberOfHours = numberOfHours;
}
public Boolean getDivision() {
return division;
}
public void setDivision(Boolean division) {
this.division = division;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public SemesterType getSemesterType() {
return semesterType;
}
public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType;
}
public String getPeriod() {
return period;
}
public void setPeriod(String period) {
this.period = period;
}
}

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

@@ -0,0 +1,10 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record DepartmentTransferRequest(
Long departmentId,
LocalDate validFrom,
String comment
) {
}

View File

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

View File

@@ -1,5 +1,8 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupResponse {
private Long id;
@@ -8,18 +11,55 @@ public class GroupResponse {
private Long educationFormId;
private String educationFormName;
private Long departmentId;
private Integer yearStartStudy;
private Integer course;
private Long specialityCode;
private Integer semester;
private Long specialtyId;
private String specialtyCode;
private String specialtyName;
private Long specialtyProfileId;
private String specialtyProfileName;
private String status;
private Boolean active;
private String studyState;
private String studyStateName;
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Long specialityCode) {
public GroupResponse(Long id,
String name,
Long groupSize,
Long educationFormId,
String educationFormName,
Long departmentId,
Integer yearStartStudy,
Integer course,
Integer semester,
Long specialtyId,
String specialtyCode,
String specialtyName,
Long specialtyProfileId,
String specialtyProfileName,
String status,
Boolean active,
String studyState,
String studyStateName) {
this.id = id;
this.name = name;
this.groupSize = groupSize;
this.educationFormId = educationFormId;
this.educationFormName = educationFormName;
this.departmentId = departmentId;
this.yearStartStudy = yearStartStudy;
this.course = course;
this.specialityCode = specialityCode;
this.semester = semester;
this.specialtyId = specialtyId;
this.specialtyCode = specialtyCode;
this.specialtyName = specialtyName;
this.specialtyProfileId = specialtyProfileId;
this.specialtyProfileName = specialtyProfileName;
this.status = status;
this.active = active;
this.studyState = studyState;
this.studyStateName = studyStateName;
}
public Long getId() {
@@ -50,7 +90,51 @@ public class GroupResponse {
return course;
}
public Integer getSemester() {
return semester;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public Long getSpecialityCode() {
return specialityCode;
return specialtyId;
}
public Long getSpecialtyId() {
return specialtyId;
}
public String getSpecialtyCode() {
return specialtyCode;
}
public String getSpecialtyName() {
return specialtyName;
}
public Long getSpecialtyProfileId() {
return specialtyProfileId;
}
public String getSpecialtyProfileName() {
return specialtyProfileName;
}
public String getStatus() {
return status;
}
public Boolean getActive() {
return active;
}
public String getStudyState() {
return studyState;
}
public String getStudyStateName() {
return studyStateName;
}
}

View File

@@ -1,171 +0,0 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LessonResponse {
private Long id;
private Long teacherId;
private String teacherName;
private Long groupId;
private String groupName;
private String educationFormName;
private Long subjectId;
private String subjectName;
private String lessonFormat;
private String typeLesson;
private Long classroomId;
private String classroomName;
private String day;
private String week;
private String time;
public LessonResponse() {
}
public LessonResponse(Long id, Long teacherId, Long groupId, Long subjectId, String day, String week, String time) {
this.id = id;
this.teacherId = teacherId;
this.groupId = groupId;
this.subjectId = subjectId;
this.day = day;
this.week = week;
this.time = time;
}
public LessonResponse(Long id, String teacherName, String groupName, String classroomName, String educationFormName, String subjectName, String typeLesson, String lessonFormat, String day, String week, String time) {
this.id = id;
this.teacherName = teacherName;
this.groupName = groupName;
this.classroomName = classroomName;
this.educationFormName = educationFormName;
this.subjectName = subjectName;
this.typeLesson = typeLesson;
this.lessonFormat = lessonFormat;
this.day = day;
this.week = week;
this.time = time;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public String getTeacherName() {
return teacherName;
}
public void setTeacherName(String teacherName) {
this.teacherName = teacherName;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
public String getTypeLesson() {
return typeLesson;
}
public void setTypeLesson(String typeLesson) {
this.typeLesson = typeLesson;
}
public String getLessonFormat() {
return lessonFormat;
}
public void setLessonFormat(String lessonFormat) {
this.lessonFormat = lessonFormat;
}
public Long getClassroomId() {
return classroomId;
}
public void setClassroomId(Long classroomId) {
this.classroomId = classroomId;
}
public String getClassroomName() {
return classroomName;
}
public void setClassroomName(String classroomName) {
this.classroomName = classroomName;
}
public String getEducationFormName() {
return educationFormName;
}
public void setEducationFormName(String educationFormName) {
this.educationFormName = educationFormName;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
public String getSubjectName() {
return subjectName;
}
public void setSubjectName(String subjectName) {
this.subjectName = subjectName;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.dto;
public record LessonTypeResponse(
Long id,
String name
) {
}

View File

@@ -8,17 +8,23 @@ public class LoginResponse {
private String role;
private String redirect;
private Long departmentId;
private Long userId;
public LoginResponse() {
}
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
this(success, message, token, role, redirect, departmentId, null);
}
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) {
this.success = success;
this.message = message;
this.token = token;
this.role = role;
this.redirect = redirect;
this.departmentId = departmentId;
this.userId = userId;
}
public boolean isSuccess() {
@@ -68,4 +74,12 @@ public class LoginResponse {
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
}

View File

@@ -0,0 +1,42 @@
package com.magistr.app.dto;
import com.magistr.app.model.ScheduleParity;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
public record RenderedLessonDto(
Long scheduleRuleId,
Long scheduleRuleSlotId,
LocalDate date,
Integer dayOfWeek,
String dayName,
Integer weekNumber,
ScheduleParity parity,
Long timeSlotId,
Integer timeSlotOrder,
LocalTime startTime,
LocalTime endTime,
Long subjectId,
String subjectName,
Long teacherId,
String teacherName,
Long classroomId,
String classroomName,
Long lessonTypeId,
String lessonTypeName,
String lessonFormat,
Long subgroupId,
String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
List<Long> groupIds,
List<String> groupNames,
String activityType,
Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson,
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,19 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.time.LocalDateTime;
public record ScheduleOverrideDto(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment,
Long createdBy,
LocalDateTime createdAt
) {
}

View File

@@ -1,129 +0,0 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.magistr.app.model.SemesterType;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ScheduleResponse {
private Long id;
private String specialityCode;
private Long departmentId;
private Long semester;
private Long groupId;
private String groupName;
private Integer groupCourse;
private Long subjectsId;
private String subjectName;
private Long lessonTypeId;
private String lessonType;
private Long numberOfHours;
private Boolean division;
private Long teacherId;
private String teacherName;
private String teacherJobTitle;
private SemesterType semesterType;
private String period;
public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.semester = semester;
this.groupId = groupId;
this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherId = teacherId;
this.semesterType = semesterType;
this.period = period;
}
public ScheduleResponse(Long id, Long departmentId, String specialityCode, Long semester, String groupName, Integer groupCourse, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.specialityCode = specialityCode;
this.semester = semester;
this.groupName = groupName;
this.groupCourse = groupCourse;
this.subjectName = subjectName;
this.lessonType = lessonType;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherName = teacherName;
this.teacherJobTitle = teacherJobTitle;
this.semesterType = semesterType;
this.period = period;
}
public Long getId() {
return id;
}
public String getSpecialityCode() {
return specialityCode;
}
public Long getDepartmentId() {
return departmentId;
}
public Long getSemester() {
return semester;
}
public Long getGroupId() {
return groupId;
}
public String getGroupName() {
return groupName;
}
public Integer getGroupCourse() {
return groupCourse;
}
public Long getSubjectsId() {
return subjectsId;
}
public String getSubjectName() {
return subjectName;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public String getLessonType() {
return lessonType;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public Boolean getDivision() {
return division;
}
public Long getTeacherId() {
return teacherId;
}
public String getTeacherName() {
return teacherName;
}
public String getTeacherJobTitle() {
return teacherJobTitle;
}
public SemesterType getSemesterType() {
return semesterType;
}
public String getPeriod() {
return period;
}
}

View File

@@ -0,0 +1,24 @@
package com.magistr.app.dto;
import com.magistr.app.model.SemesterType;
import java.util.List;
public record ScheduleRuleDto(
Long id,
Long subjectId,
String subjectName,
Long semesterId,
String academicYearTitle,
SemesterType semesterType,
Integer lectureAcademicHours,
Integer laboratoryAcademicHours,
Integer practiceAcademicHours,
Integer lectureStartWeek,
Integer laboratoryStartWeek,
Integer practiceStartWeek,
List<Long> groupIds,
List<String> groupNames,
List<ScheduleRuleSlotDto> slots
) {
}

View File

@@ -0,0 +1,27 @@
package com.magistr.app.dto;
import com.magistr.app.model.ScheduleParity;
import java.util.List;
public record ScheduleRuleSlotDto(
Long id,
Integer dayOfWeek,
String dayName,
ScheduleParity parity,
Long timeSlotId,
Integer timeSlotOrder,
String timeSlotLabel,
Long subgroupId,
String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
Long teacherId,
String teacherName,
Long classroomId,
String classroomName,
Long lessonTypeId,
String lessonTypeName,
String lessonFormat
) {
}

View File

@@ -0,0 +1,15 @@
package com.magistr.app.dto;
import com.magistr.app.model.SemesterType;
import java.time.LocalDate;
public record SemesterDto(
Long id,
Long academicYearId,
String academicYearTitle,
SemesterType semesterType,
LocalDate startDate,
LocalDate endDate
) {
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
public record SpecialtyProfileDto(
Long id,
Long specialtyId,
String specialtyCode,
String specialtyName,
String name,
String description
) {
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.dto;
public record SubgroupDto(
Long id,
Long groupId,
String groupName,
String name,
Integer studentCapacity
) {
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
public record SubjectCommentDto(
Long id,
Long subjectId,
Long authorId,
String authorName,
String comment,
LocalDateTime createdAt
) {
}

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

@@ -0,0 +1,16 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherDepartmentAssignmentDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
String departmentName,
LocalDate validFrom,
LocalDate validTo,
Boolean primaryAssignment,
String comment
) {
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TimeSlotDateAssignmentDto(
Long id,
LocalDate date,
Long scopeId,
String scopeName
) {
}

View File

@@ -0,0 +1,16 @@
package com.magistr.app.dto;
import java.time.LocalTime;
public record TimeSlotDto(
Long id,
Integer orderNumber,
Long scopeId,
String scopeName,
String scopeApplyMode,
Integer scopeDayOfWeek,
LocalTime startTime,
LocalTime endTime,
Integer durationMinutes
) {
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record TimeSlotScopeDto(
Long id,
String code,
String name,
String applyMode,
Integer dayOfWeek,
Boolean systemScope,
Integer displayOrder
) {
}

View File

@@ -12,6 +12,7 @@ public class UserResponse {
private String jobTitle;
private String departmentName;
private Long departmentId;
private String status;
public UserResponse() {
}
@@ -69,4 +70,16 @@ public class UserResponse {
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record WorkloadSummaryDto(
Long id,
String name,
Long departmentId,
String departmentName,
long lessonCount,
int academicHours,
long occupiedSlotCount
) {
}

View File

@@ -0,0 +1,90 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "academic_calendars")
public class AcademicCalendar {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@ManyToOne(optional = false)
@JoinColumn(name = "academic_year_id", nullable = false)
private AcademicYear academicYear;
@ManyToOne(optional = false)
@JoinColumn(name = "specialty_id", nullable = false)
private Speciality speciality;
@ManyToOne(optional = false)
@JoinColumn(name = "specialty_profile_id", nullable = false)
private SpecialtyProfile specialtyProfile;
@ManyToOne(optional = false)
@JoinColumn(name = "study_form_id", nullable = false)
private EducationForm studyForm;
@Column(name = "course_count", nullable = false)
private Integer courseCount;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public AcademicYear getAcademicYear() {
return academicYear;
}
public void setAcademicYear(AcademicYear academicYear) {
this.academicYear = academicYear;
}
public Speciality getSpeciality() {
return speciality;
}
public void setSpeciality(Speciality speciality) {
this.speciality = speciality;
}
public SpecialtyProfile getSpecialtyProfile() {
return specialtyProfile;
}
public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) {
this.specialtyProfile = specialtyProfile;
}
public EducationForm getStudyForm() {
return studyForm;
}
public void setStudyForm(EducationForm studyForm) {
this.studyForm = studyForm;
}
public Integer getCourseCount() {
return courseCount;
}
public void setCourseCount(Integer courseCount) {
this.courseCount = courseCount;
}
}

View File

@@ -0,0 +1,86 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "academic_calendar_activity_types")
public class AcademicCalendarActivityType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 10)
private String code;
@Column(nullable = false)
private String name;
@Column(name = "allow_schedule", nullable = false)
private Boolean allowSchedule;
@Column(name = "color_code", nullable = false, length = 7)
private String colorCode;
@Column(name = "display_order", nullable = false)
private Integer displayOrder;
@Column(columnDefinition = "TEXT")
private String description;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Boolean getAllowSchedule() {
return allowSchedule;
}
public void setAllowSchedule(Boolean allowSchedule) {
this.allowSchedule = allowSchedule;
}
public String getColorCode() {
return colorCode;
}
public void setColorCode(String colorCode) {
this.colorCode = colorCode;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}

View File

@@ -0,0 +1,90 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "academic_calendar_days")
public class AcademicCalendarDay {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "calendar_id", nullable = false)
private AcademicCalendar academicCalendar;
@Column(name = "course_number", nullable = false)
private Integer courseNumber;
@Column(nullable = false)
private LocalDate date;
@Column(name = "week_number", nullable = false)
private Integer weekNumber;
@Column(name = "day_of_week", nullable = false)
private Integer dayOfWeek;
@ManyToOne(optional = false)
@JoinColumn(name = "activity_type_id", nullable = false)
private AcademicCalendarActivityType activityType;
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 getCourseNumber() {
return courseNumber;
}
public void setCourseNumber(Integer courseNumber) {
this.courseNumber = courseNumber;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public Integer getWeekNumber() {
return weekNumber;
}
public void setWeekNumber(Integer weekNumber) {
this.weekNumber = weekNumber;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public AcademicCalendarActivityType getActivityType() {
return activityType;
}
public void setActivityType(AcademicCalendarActivityType activityType) {
this.activityType = activityType;
}
}

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,55 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "academic_years")
public class AcademicYear {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String title;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
}

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

@@ -6,7 +6,7 @@ import java.util.Set;
@Entity
@Table(name = "classrooms")
public class Classroom {
public class Classroom extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -18,6 +18,11 @@ public class Classroom {
@Column(nullable = false)
private Integer capacity;
@Column(length = 50)
private String building;
private Integer floor;
@Column(name = "is_available", nullable = false)
private Boolean isAvailable = true;
@@ -52,6 +57,22 @@ public class Classroom {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="departments")
public class Department {
public class Department extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "education_forms")
public class EducationForm {
public class EducationForm extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "equipments")
public class Equipment {
public class Equipment extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -1,122 +0,0 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "lessons")
public class Lesson {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "teacher_id", nullable = false)
private Long teacherId;
@Column(name = "group_id", nullable = false)
private Long groupId;
@Column(name = "subject_id", nullable = false)
private Long subjectId;
@Column(name = "lesson_format", nullable = false, length = 255)
private String lessonFormat;
@Column(name = "type_lesson", nullable = false, length = 255)
private String typeLesson;
@Column(name = "classroom_id", nullable = false)
private Long classroomId;
@Column(name = "day", nullable = false, length = 255)
private String day;
@Column(name = "week", nullable = false, length = 255)
private String week;
@Column(name = "time", nullable = false, length = 255)
private String time;
public Lesson() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
public String getLessonFormat() {
return lessonFormat;
}
public void setLessonFormat(String lessonFormat) {
this.lessonFormat = lessonFormat;
}
public String getTypeLesson() {
return typeLesson;
}
public void setTypeLesson(String typeLesson) {
this.typeLesson = typeLesson;
}
public Long getClassroomId() {
return classroomId;
}
public void setClassroomId(Long classroomId) {
this.classroomId = classroomId;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

@@ -0,0 +1,107 @@
package com.magistr.app.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDate;
import java.time.LocalDateTime;
@MappedSuperclass
public abstract class LifecycleEntity {
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_ARCHIVED = "ARCHIVED";
@Column(name = "status", nullable = false, length = 20)
private String status = STATUS_ACTIVE;
@Column(name = "active_from")
private LocalDate activeFrom;
@Column(name = "active_to")
private LocalDate activeTo;
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@Column(name = "archive_reason")
private String archiveReason;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status == null || status.isBlank() ? STATUS_ACTIVE : status;
}
public LocalDate getActiveFrom() {
return activeFrom;
}
public void setActiveFrom(LocalDate activeFrom) {
this.activeFrom = activeFrom;
}
public LocalDate getActiveTo() {
return activeTo;
}
public void setActiveTo(LocalDate activeTo) {
this.activeTo = activeTo;
}
public LocalDateTime getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
this.archivedAt = archivedAt;
}
public String getArchiveReason() {
return archiveReason;
}
public void setArchiveReason(String archiveReason) {
this.archiveReason = archiveReason;
}
@JsonIgnore
public boolean isActiveRecord() {
return !STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isArchivedRecord() {
return STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isActiveOn(LocalDate date) {
if (date == null) {
return isActiveRecord();
}
if (isArchivedRecord() && activeTo == null) {
return false;
}
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
return afterStart && beforeEnd;
}
public void archive(String reason) {
status = STATUS_ARCHIVED;
archivedAt = LocalDateTime.now();
activeTo = LocalDate.now();
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
}
public void restore() {
status = STATUS_ACTIVE;
archivedAt = null;
archiveReason = null;
activeTo = null;
}
}

View File

@@ -2,6 +2,9 @@ package com.magistr.app.model;
public enum Role {
ADMIN,
EDUCATION_OFFICE,
DEPARTMENT,
SCHEDULE_VIEWER,
TEACHER,
STUDENT
}

View File

@@ -1,147 +0,0 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="schedule_data")
public class ScheduleData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="department_id", nullable = false)
private Long departmentId;
@Column(name="semester", nullable = false)
private Long semester;
@Column(name="group_id", nullable = false)
private Long groupId;
@Column(name="subjects_id", nullable = false)
private Long subjectsId;
@Column(name="lesson_type_id", nullable = false)
private Long lessonTypeId;
@Column(name="number_of_hours", nullable = false)
private Long numberOfHours;
@Column(name="is_division", nullable = false)
private Boolean division;
@Column(name="teacher_id", nullable = false)
private Long teacherId;
@Enumerated(EnumType.STRING)
@Column(name="semester_type", nullable = false)
private SemesterType semesterType;
@Column(name="period", nullable = false)
private String period;
public ScheduleData() {}
public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.semester = semester;
this.groupId = groupId;
this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherId = teacherId;
this.semesterType = semesterType;
this.period = period;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getSemester() {
return semester;
}
public void setSemester(Long semester) {
this.semester = semester;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectsId() {
return subjectsId;
}
public void setSubjectsId(Long subjectsId) {
this.subjectsId = subjectsId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public void setNumberOfHours(Long numberOfHours) {
this.numberOfHours = numberOfHours;
}
public Boolean getDivision() {
return division;
}
public void setDivision(Boolean division) {
this.division = division;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public SemesterType getSemesterType() {
return semesterType;
}
public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType;
}
public String getPeriod() {
return period;
}
public void setPeriod(String period) {
this.period = period;
}
}

View File

@@ -0,0 +1,55 @@
package com.magistr.app.model;
import java.util.Locale;
public enum ScheduleLessonCategory {
LECTURE("Лекции"),
LABORATORY("Лабораторные"),
PRACTICE("Практики");
private final String displayName;
ScheduleLessonCategory(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
public static ScheduleLessonCategory fromLessonType(LessonType lessonType) {
if (lessonType == null || lessonType.getLessonType() == null) {
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
}
String name = lessonType.getLessonType()
.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;
}
if (laboratory) {
return LABORATORY;
}
if (practice) {
return PRACTICE;
}
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
+ "\" должен быть лекцией, практикой или лабораторной работой");
}
}

View File

@@ -0,0 +1,133 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "schedule_overrides")
public class ScheduleOverride {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "base_rule_slot_id", nullable = false)
private ScheduleRuleSlot baseRuleSlot;
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(nullable = false, length = 20)
private String action;
@ManyToOne
@JoinColumn(name = "new_time_slot_id")
private TimeSlot newTimeSlot;
@ManyToOne
@JoinColumn(name = "new_classroom_id")
private Classroom newClassroom;
@ManyToOne
@JoinColumn(name = "new_teacher_id")
private User newTeacher;
@Column(name = "new_lesson_format", length = 30)
private String newLessonFormat;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
public Long getId() {
return id;
}
public ScheduleRuleSlot getBaseRuleSlot() {
return baseRuleSlot;
}
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) {
this.baseRuleSlot = baseRuleSlot;
}
public LocalDate getLessonDate() {
return lessonDate;
}
public void setLessonDate(LocalDate lessonDate) {
this.lessonDate = lessonDate;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public TimeSlot getNewTimeSlot() {
return newTimeSlot;
}
public void setNewTimeSlot(TimeSlot newTimeSlot) {
this.newTimeSlot = newTimeSlot;
}
public Classroom getNewClassroom() {
return newClassroom;
}
public void setNewClassroom(Classroom newClassroom) {
this.newClassroom = newClassroom;
}
public User getNewTeacher() {
return newTeacher;
}
public void setNewTeacher(User newTeacher) {
this.newTeacher = newTeacher;
}
public String getNewLessonFormat() {
return newLessonFormat;
}
public void setNewLessonFormat(String newLessonFormat) {
this.newLessonFormat = newLessonFormat;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum ScheduleParity {
BOTH,
EVEN,
ODD
}

View File

@@ -0,0 +1,231 @@
package com.magistr.app.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "schedule_rules")
public class ScheduleRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "subject_id", nullable = false)
private Subject subject;
@ManyToOne(optional = false)
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(nullable = false, length = 20)
private String status = LifecycleEntity.STATUS_ACTIVE;
@Column(name = "valid_from")
private LocalDate validFrom;
@Column(name = "valid_to")
private LocalDate validTo;
@Column(name = "lecture_academic_hours", nullable = false)
private Integer lectureAcademicHours = 0;
@Column(name = "laboratory_academic_hours", nullable = false)
private Integer laboratoryAcademicHours = 0;
@Column(name = "practice_academic_hours", nullable = false)
private Integer practiceAcademicHours = 0;
@Column(name = "lecture_start_week", nullable = false)
private Integer lectureStartWeek = 1;
@Column(name = "laboratory_start_week", nullable = false)
private Integer laboratoryStartWeek = 1;
@Column(name = "practice_start_week", nullable = false)
private Integer practiceStartWeek = 1;
@ManyToMany
@JoinTable(
name = "schedule_rule_groups",
joinColumns = @JoinColumn(name = "schedule_rule_id"),
inverseJoinColumns = @JoinColumn(name = "group_id")
)
private Set<StudentGroup> groups = new HashSet<>();
@OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<ScheduleRuleSlot> slots = new HashSet<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Subject getSubject() {
return subject;
}
public void setSubject(Subject subject) {
this.subject = subject;
}
public Semester getSemester() {
return semester;
}
public void setSemester(Semester semester) {
this.semester = semester;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status;
}
public LocalDate getValidFrom() {
return validFrom;
}
public void setValidFrom(LocalDate validFrom) {
this.validFrom = validFrom;
}
public LocalDate getValidTo() {
return validTo;
}
public void setValidTo(LocalDate validTo) {
this.validTo = validTo;
}
public Integer getLectureAcademicHours() {
return lectureAcademicHours;
}
public void setLectureAcademicHours(Integer lectureAcademicHours) {
this.lectureAcademicHours = lectureAcademicHours;
}
public Integer getLaboratoryAcademicHours() {
return laboratoryAcademicHours;
}
public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) {
this.laboratoryAcademicHours = laboratoryAcademicHours;
}
public Integer getPracticeAcademicHours() {
return practiceAcademicHours;
}
public void setPracticeAcademicHours(Integer practiceAcademicHours) {
this.practiceAcademicHours = practiceAcademicHours;
}
public Integer getLectureStartWeek() {
return lectureStartWeek;
}
public void setLectureStartWeek(Integer lectureStartWeek) {
this.lectureStartWeek = lectureStartWeek;
}
public Integer getLaboratoryStartWeek() {
return laboratoryStartWeek;
}
public void setLaboratoryStartWeek(Integer laboratoryStartWeek) {
this.laboratoryStartWeek = laboratoryStartWeek;
}
public Integer getPracticeStartWeek() {
return practiceStartWeek;
}
public void setPracticeStartWeek(Integer practiceStartWeek) {
this.practiceStartWeek = practiceStartWeek;
}
public int academicHoursFor(ScheduleLessonCategory category) {
return switch (category) {
case LECTURE -> valueOrZero(lectureAcademicHours);
case LABORATORY -> valueOrZero(laboratoryAcademicHours);
case PRACTICE -> valueOrZero(practiceAcademicHours);
};
}
public int startWeekFor(ScheduleLessonCategory category) {
return switch (category) {
case LECTURE -> valueOrDefault(lectureStartWeek, 1);
case LABORATORY -> valueOrDefault(laboratoryStartWeek, 1);
case PRACTICE -> valueOrDefault(practiceStartWeek, 1);
};
}
@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) {
return valueOrDefault(value, 0);
}
private int valueOrDefault(Integer value, int defaultValue) {
return value == null ? defaultValue : value;
}
public Set<StudentGroup> getGroups() {
return groups;
}
public void setGroups(Set<StudentGroup> groups) {
this.groups = groups;
}
public Set<ScheduleRuleSlot> getSlots() {
return slots;
}
public void setSlots(Set<ScheduleRuleSlot> slots) {
this.slots = slots;
}
}

View File

@@ -0,0 +1,164 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Entity
@Table(name = "schedule_rule_slots")
public class ScheduleRuleSlot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "schedule_rule_id", nullable = false)
private ScheduleRule scheduleRule;
@Column(name = "day_of_week", nullable = false)
private Integer dayOfWeek;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private ScheduleParity parity;
@ManyToOne(optional = false)
@JoinColumn(name = "time_slot_id", nullable = false)
private TimeSlot timeSlot;
@ManyToMany
@JoinTable(
name = "schedule_rule_slot_subgroups",
joinColumns = @JoinColumn(name = "schedule_rule_slot_id"),
inverseJoinColumns = @JoinColumn(name = "subgroup_id")
)
private Set<Subgroup> subgroups = new LinkedHashSet<>();
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "classroom_id", nullable = false)
private Classroom classroom;
@ManyToOne(optional = false)
@JoinColumn(name = "lesson_type_id", nullable = false)
private LessonType lessonType;
@Column(name = "lesson_format", nullable = false, length = 30)
private String lessonFormat;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public ScheduleRule getScheduleRule() {
return scheduleRule;
}
public void setScheduleRule(ScheduleRule scheduleRule) {
this.scheduleRule = scheduleRule;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public ScheduleParity getParity() {
return parity;
}
public void setParity(ScheduleParity parity) {
this.parity = parity;
}
public TimeSlot getTimeSlot() {
return timeSlot;
}
public void setTimeSlot(TimeSlot timeSlot) {
this.timeSlot = timeSlot;
}
public Long getSubgroupId() {
return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
}
public Subgroup getSubgroup() {
return subgroups.stream().findFirst().orElse(null);
}
public void setSubgroup(Subgroup subgroup) {
subgroups.clear();
if (subgroup != null) {
subgroups.add(subgroup);
}
}
public Set<Subgroup> getSubgroups() {
return subgroups;
}
public void setSubgroups(Set<Subgroup> subgroups) {
this.subgroups.clear();
if (subgroups != null) {
this.subgroups.addAll(subgroups);
}
}
public List<Long> getSubgroupIds() {
return subgroups.stream()
.map(Subgroup::getId)
.toList();
}
public List<String> getSubgroupNames() {
return subgroups.stream()
.map(Subgroup::getName)
.toList();
}
public User getTeacher() {
return teacher;
}
public void setTeacher(User teacher) {
this.teacher = teacher;
}
public Classroom getClassroom() {
return classroom;
}
public void setClassroom(Classroom classroom) {
this.classroom = classroom;
}
public LessonType getLessonType() {
return lessonType;
}
public void setLessonType(LessonType lessonType) {
this.lessonType = lessonType;
}
public String getLessonFormat() {
return lessonFormat;
}
public void setLessonFormat(String lessonFormat) {
this.lessonFormat = lessonFormat;
}
}

View File

@@ -0,0 +1,68 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "semesters")
public class Semester {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "academic_year_id", nullable = false)
private AcademicYear academicYear;
@Enumerated(EnumType.STRING)
@Column(name = "semester_type", nullable = false, length = 20)
private SemesterType semesterType;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public AcademicYear getAcademicYear() {
return academicYear;
}
public void setAcademicYear(AcademicYear academicYear) {
this.academicYear = academicYear;
}
public SemesterType getSemesterType() {
return semesterType;
}
public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType;
}
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
}

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="specialties")
public class Speciality {
public class Speciality extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -0,0 +1,54 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "specialty_profiles")
public class SpecialtyProfile extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "specialty_id", nullable = false)
private Speciality speciality;
@Column(nullable = false, length = 500)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Speciality getSpeciality() {
return speciality;
}
public void setSpeciality(Speciality speciality) {
this.speciality = speciality;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}

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