124 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
ProstoDenya01
cd6cc6f5f7 Поправил создание кафедрального файла 2026-03-31 13:20:31 +03:00
2be2534a1e Merge remote-tracking branch 'origin/department_dev' into department_dev 2026-03-31 00:49:45 +03:00
b14d937062 Изменил страницу "Кафедра",добавлена модалка с полями для создания записи в Кафедральный файлик 2026-03-31 00:49:37 +03:00
9d06c99d06 Поправил документацию по API 2026-03-30 22:06:16 +03:00
Zuev
522bc97b8c добавил всплывающий текст у свёрнутой боковой панели 2026-03-28 14:28:13 +03:00
Zuev
d0a8148fa0 исправил боковую панель. теперь на десктопе она сворачивается не полностью 2026-03-28 14:20:46 +03:00
ProstoDenya01
0b9d063266 Поправил ответ для пользователей, чтобы приходило название кафедры, а не ID 2026-03-27 19:02:27 +03:00
ProstoDenya01
6f33e23e17 Добавил методы на создание и удаление данных в кафедральном файле 2026-03-27 18:24:08 +03:00
Zuev
bfdcb58c7d изменил дизайн выпадающих списков 2026-03-27 16:08:44 +03:00
Zuev
e015758caf обновил документацию 2026-03-27 15:24:29 +03:00
Zuev
6be8db0cd0 сделал кнопку настроек, вкладку настроек и сворачивание боковой панели 2026-03-27 15:03:52 +03:00
ProstoDenya01
7a2c385257 Реализовал метод на получение данных для расписания по нужным критериям. Обновил БД 2026-03-26 20:08:17 +03:00
f7483e7aeb Изменил страницу "Кафедра", добавлена фильтрация и добавление блоков 2026-03-26 00:37:31 +03:00
Zuev
55da934545 Merge branch 'department_dev' of ssh://gitea.zuev.company:2222/Zuev/magistr into department_dev 2026-03-25 23:53:35 +03:00
Zuev
e71bcee9b5 chore: Configure database healthcheck, backend service dependency on DB health, and disable OpenTelemetry SDK. 2026-03-25 23:53:23 +03:00
7ce0d1e501 Добавлена страница создание кафедры/специальности 2026-03-25 23:52:48 +03:00
Zuev
3861fa05b5 feat: add frontend-design skill with its documentation and license, and update gitignore. 2026-03-25 23:52:48 +03:00
Zuev
599e284ea9 feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md. 2026-03-25 23:52:48 +03:00
Zuev
ec7e615557 docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md. 2026-03-25 23:52:48 +03:00
Zuev
9e7b35aa0b feat: Add OpenTelemetry integration by creating otel.js and importing it into main.js. 2026-03-25 23:52:48 +03:00
ProstoDenya01
4915e6f33b Добавил метод на получение списка данных для расписания 2026-03-25 23:49:10 +03:00
ProstoDenya01
798d61c7ea Добавил возможность создавать, просматривать и удалять данные дисциплин и кафедр. 2026-03-25 22:41:53 +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
ProstoDenya01
0817961d97 Добавил для пользователей, дисциплин и групп получение списка по конкретной кафедре. 2026-03-21 13:00:30 +03:00
ProstoDenya01
49ca2e17b6 Добавил все новые поля (departmentId, code) для дисциплин. Добавил логирование для SubjectController.java 2026-03-21 11:41:01 +03:00
ProstoDenya01
c07e49ca98 Добавил все новые поля (departmentId, course) для групп. Добавил логирование для GroupController.java 2026-03-21 10:58:27 +03:00
dipatrik10
b89d1c7f72 Добавил все новые поля (fullName, jobTitle, departmentId) для пользователей. Добавил логирование для UserController.java 2026-03-21 00:20:34 +03:00
6774ebb766 Доработано создание пользователя и отображение новых параметров пользователя в таблице 2026-03-20 00:33:27 +03:00
f7fb524bb0 Доработано создание групп и отображение новых параметров групп в таблице 2026-03-20 00:27:01 +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
d78e675a71 Доработано создание дисциплин и отображение новых параметров дисциплин в таблице+страничка с кафедрами(голая) 2026-03-19 23:42:09 +03:00
Zuev
8cf086d3e9 Remove legacy V2 migration
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 2m34s
2026-03-19 04:37:11 +03:00
f39c3d1bbb Merge pull request 'Fix database migration: merge V2 into V1 and remove V2' (#9) from department_dev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 32s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m7s
Reviewed-on: #9
2026-03-19 01:32:07 +00:00
Zuev
dc1c343174 Fix database migration: merge V2 into V1 and remove V2 2026-03-19 04:30:31 +03:00
74fcd07e25 Merge pull request 'department_dev' (#8) from department_dev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 4m9s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m5s
Reviewed-on: #8
2026-03-19 00:56:35 +00:00
Zuev
8ced8ae669 feat: Integrate OpenTelemetry for distributed tracing in both frontend and backend applications. 2026-03-19 03:55:22 +03:00
dipatrik10
f519650bbb Исправил ошибку в коментах 2026-03-18 20:48:36 +03:00
dipatrik10
7fac9f744d Добавил комментарии для всех колонок таблиц 2026-03-18 20:45:39 +03:00
dipatrik10
18d099460d Поправил создание таблицы 2026-03-18 20:16:34 +03:00
dipatrik10
59b6704be9 Добавил комментарии к БД 2026-03-18 20:05:30 +03:00
ProstoDenya01
220b99594f Обновил таблицы и создал новые для данных расписания. 2026-03-18 15:44:05 +03:00
ProstoDenya01
c10198515c Обновил таблицы и создал новые для данных расписания. 2026-03-18 15:39:11 +03:00
Zuev
a8144acb8b config: Update application properties.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 4m27s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m20s
2026-03-18 02:00:49 +03:00
Zuev
04feb5a3c3 feat: Enhance Dockerfile security with non-root users and correct file permissions, and adjust Gitea workflow action versions.
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 1m25s
2026-03-17 02:47:57 +03:00
Zuev
d69eab1c12 chore: Update Gitea workflow actions to newer versions, add AGENTS.md, and modify gitignore.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m37s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m10s
2026-03-17 01:30:48 +03:00
ProstoDenya01
f3ea05cd17 Merge remote-tracking branch 'origin/personal-schedule'
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 15s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m38s
2026-03-15 16:00:48 +03:00
Zuev
9f124c52a5 refactor: Integrate Flyway for database migrations and simplify Docker Compose and tenant configurations.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m44s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m19s
2026-03-13 04:35:50 +03:00
Zuev
10c06e726a Update tenant data source configuration.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 28s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m19s
2026-03-13 02:48:03 +03:00
Zuev
9d2de1faaf feat: Configure and route multi-tenant data sources using an interceptor.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Failing after 22s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Has been skipped
2026-03-13 02:36:01 +03:00
Zuev
59caa9d6cc feat: Implement dynamic tenant configuration watching and updating.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 5m59s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 12s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 44s
2026-03-13 02:00:24 +03:00
Zuev
bad1215341 баг
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 38s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 20s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m8s
2026-03-13 01:04:26 +03:00
Zuev
ccdc371c3a баг
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 30s
Build and Push Docker Images / build-and-push-frontend (push) Failing after 20s
Build and Push Docker Images / deploy-to-k8s (push) Has been skipped
2026-03-13 00:56:49 +03:00
Zuev
4c2293b620 feat: Implement database initialization using init.sql and update DataInitializer.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 34s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 45s
2026-03-13 00:21:06 +03:00
Zuev
6ea420e529 1
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 30s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 50s
2026-03-13 00:07:54 +03:00
Zuev
75b1ad166e refactor: update tenant data source configuration and routing logic.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 31s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 46s
2026-03-12 23:53:20 +03:00
Zuev
abad3776db feat: Add H2 in-memory database dependency for fallback scenarios.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m32s
2026-03-12 22:42:57 +03:00
Zuev
13b3a5c481 refactor: Directly configure LocalContainerEntityManagerFactoryBean with HibernateJpaVendorAdapter and rename the primary data source bean.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 12s
Build and Push Docker Images / deploy-to-k8s (push) Has been cancelled
2026-03-12 22:38:00 +03:00
Zuev
3579ef9f1c config: Exclude DataSourceAutoConfiguration and update tenant database connection URL in tenants.json.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Has been cancelled
2026-03-12 22:31:09 +03:00
Zuev
14cc006f06 feat: Implement multi-tenancy with dynamic data source routing and introduce a database management UI.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 33s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 16s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m31s
2026-03-12 22:15:28 +03:00
9e55472de7 Merge pull request 'Добавил для групп поле численности.' (#7) from personal-schedule into main
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 32s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m32s
Reviewed-on: #7
2026-03-12 14:20:33 +00:00
256 changed files with 36009 additions and 3810 deletions

View File

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

View File

@@ -0,0 +1,86 @@
---
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` |
| `frontend/admin/settings/**` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` |
## Пошаговая инструкция
### 1. Определить затронутые файлы документации
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
### 2. Прочитать текущую документацию
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
### 3. Внести точечные изменения
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
#### Новый контроллер → `docs/API.md`
Добавить новую секцию с описанием эндпоинтов:
- Метод + URL
- Тело запроса (JSON пример)
- Ответ (JSON пример)
- Валидация
#### Новая миграция → `docs/DATABASE.md`
- Добавить новую таблицу в ER-диаграмму (Mermaid)
- Добавить описание таблицы и колонок
- Добавить запись в таблицу «Текущие миграции»
#### Новый view → `docs/FRONTEND.md`
- Добавить в дерево файлов
- Добавить в таблицу «Разделы админ-панели»
### 4. Обновить AGENTS.md (при необходимости)
Если изменения затрагивают:
- Структуру директорий → обновить дерево в `AGENTS.md`
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
### 5. Сообщить пользователю
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
## Правила
1. **Язык:** Вся документация на русском языке
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Создание выразительных, готовых к продакшену frontend-интерфейсов с высоким качеством дизайна. Используйте этот навык, когда пользователь просит разработать веб-компоненты, страницы, артефакты, постеры или приложения (например, сайты, лендинги, дашборды, React-компоненты, HTML/CSS верстку или когда нужно стилизовать/улучшить любой веб-интерфейс). Генерирует креативный, отточенный код и UI-дизайн, избегая шаблонной эстетики ИИ.
license: Полные условия в LICENSE.txt
---
Этот навык направляет создание выразительных, готовых к продакшену frontend-интерфейсов, которые избегают шаблонной "ИИ-эстетики". Создавайте реально работающий код с исключительным вниманием к эстетическим деталям и творческим решениям.
Пользователь предоставляет требования к фронтенду: компонент, страницу, приложение или интерфейс для разработки. Требования могут включать контекст о цели, аудитории или технических ограничениях.
## Дизайн-мышление
Перед написанием кода поймите контекст и примите СМЕЛОЕ эстетическое направление:
- **Цель**: Какую проблему решает этот интерфейс? Кто им пользуется?
- **Тон**: Выберите крайность: брутальный минимализм, максималистский хаос, ретро-футуризм, органический/природный, люксовый/утонченный, игривый/игрушечный, редакционный/журнальный, брутализм/грубый, арт-деко/геометрический, мягкий/пастельный, индустриальный/утилитарный и т.д. Вариантов очень много. Используйте их для вдохновения, но создайте дизайн, верный выбранному эстетическому направлению.
- **Ограничения**: Технические требования (фреймворк, производительность, доступность).
- **Отличительная черта**: Что делает это НЕЗАБЫВАЕМЫМ? Какую единственную вещь кто-то запомнит?
**КРИТИЧЕСКИ ВАЖНО**: Выберите четкое концептуальное направление и выполните его с точностью. Смелый максимализм и утонченный минимализм — оба работают, ключ кроется в осознанности намерений, а не в интенсивности.
Затем реализуйте рабочий код (HTML/CSS/JS, React, Vue и т.д.), который:
- Готов к продакшену и функционален
- Визуально поразителен и легко запоминается
- Согласован с четкой эстетической точкой зрения
- Тщательно проработан в каждой детали
## Руководство по эстетике фронтенда
Сфокусируйтесь на:
- **Типографика**: Выбирайте шрифты, которые красивы, уникальны и интересны. Избегайте общих шрифтов, таких как Arial и Inter; вместо этого делайте выбор в пользу выразительных, неожиданных и характерных вариантов, которые повышают уровень эстетики фронтенда. Сочетайте акцидентный шрифт (display) с утонченным текстовым (body).
- **Цвет и тема**: Придерживайтесь согласованной эстетики. Используйте CSS-переменные для консистентности. Доминирующие цвета с резкими акцентами работают намного лучше, чем робкие, равномерно распределенные палитры.
- **Анимация (Motion)**: Используйте анимации для эффектов и микро-взаимодействий. Отдавайте предпочтение CSS-решениям для HTML. Используйте библиотеки анимаций для React, если они доступны. Фокусируйтесь на моментах с высоким влиянием: одна хорошо срежиссированная загрузка страницы с каскадным появлением элементов (animation-delay) создает больше восторга, чем множество разрозненных микро-взаимодействий. Используйте триггеры при скролле (scroll-triggering) и состояния наведения (hover), которые удивляют.
- **Пространственная композиция**: Неожиданные макеты. Асимметрия. Перекрытие. Диагональное направление. Элементы, ломающие сетку. Обильное негативное пространство ИЛИ контролируемая плотность элементов.
- **Фоны и визуальные детали**: Создавайте атмосферу и глубину вместо использования скучных сплошных цветов по умолчанию. Добавляйте контекстуальные эффекты и текстуры, соответствующие общей эстетике. Применяйте творческие формы: градиентные сетки, шумовые текстуры, геометрические паттерны, слоистые прозрачности, драматичные тени, декоративные рамки, кастомные курсоры и эффекты зернистости (grain).
НИКОГДА не используйте шаблонную сгенерированную ИИ эстетику: заезженные семейства шрифтов (Inter, Roboto, Arial, системные шрифты), клишированные цветовые схемы (особенно фиолетовые градиенты на белом фоне), предсказуемые макеты и паттерны компонентов, а также типовой скучный дизайн без характера, не учитывающий контекст.
Интерпретируйте творчески и делайте неожиданные выборы, которые кажутся действительно разработанными под данный контекст. Ни один дизайн не должен быть шаблонным ("под копирку"). Варьируйте между светлыми и темными темами, разными шрифтами, различной эстетикой. НИКОГДА не сходитесь к общим выборам (например, Space Grotesk) в разных генерациях кода.
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.

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

0
.gitea/workflows/docker-build.yaml Normal file → Executable file
View File

5
.gitignore vendored
View File

@@ -1,5 +1,6 @@
db/data/
.env
backend/tenants.json
# Игнорируем временные файлы сборки (на будущее)
backend/target/
@@ -7,9 +8,7 @@ backend/build/
frontend/node_modules/
frontend/dist/
.agents
.idea/
.vscode/
*.DS_Store
AGENTS.md
GEMINI.md
skills-lock.json

91
AGENTS.md Executable file
View File

@@ -0,0 +1,91 @@
# AGENTS.md - Руководство для агентных помощников
## Обзор проекта
Проект представляет собой систему управления университетским расписанием.
- **Backend**: Java 17, Spring Boot 3.2.5 (Мультитенантная архитектура: отдельная БД для каждого клиента)
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
- **Локальный URL**: localhost:80
- **Продакшн URL**: https://magistr.zuev.company
---
## Структура директорий
```
magistr/
├── backend/ # Java Spring Boot приложение
│ └── src/main/java/com/magistr/app/
│ ├── controller/ # REST контроллеры
│ ├── model/ # JPA сущности
│ ├── dto/ # Data Transfer Objects
│ ├── repository/ # Spring Data JPA репозитории
│ ├── config/ # Конфигурация приложения
│ ├── utils/ # Утилиты
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта
├── compose.yaml # Docker Compose конфигурация
└── .env # Переменные окружения
```
**Внешние зависимости (родительская директория)**
На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
---
## Быстрый справочник команд
```bash
# Сборка и запуск
docker compose up -d --build
# Полный сброс БД
docker compose down -v && docker compose up -d
# Логи конкретного сервиса
docker compose logs -f backend
```
Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md).
---
## Критические правила для агентов
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования
- **Все ответы и комментарии на русском языке**
- Сообщения об ошибках и логи на русском
- Пользовательский интерфейс на русском
---
## Подробная документация
Полная документация проекта находится в папке `docs/`:
| Документ | Содержание |
|----------|-----------|
| [`docs/README.md`](docs/README.md) | Обзор проекта, стек технологий, быстрый старт |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Архитектура системы, мультитенантность, аутентификация |
| [`docs/BUSINESS_LOGIC.md`](docs/BUSINESS_LOGIC.md) | Бизнес-логика, ролевая модель, правила расписания |
| [`docs/DATABASE.md`](docs/DATABASE.md) | Схема БД (ER-диаграмма), описание всех таблиц, Flyway |
| [`docs/API.md`](docs/API.md) | REST API эндпоинты с примерами запросов и ответов |
| [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта |
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность |
| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz |
| [`docs/UI_COMPONENTS.md`](docs/UI_COMPONENTS.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

@@ -4,9 +4,16 @@ COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
FROM eclipse-temurin:17-jre-alpine
# Best practice: run as a non-root user
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]

View File

@@ -32,6 +32,12 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Flyway Database Migrations -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
@@ -43,6 +49,33 @@
<groupId>org.springframework.security</groupId>
<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>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- OpenTelemetry API for custom span attributes -->
<dependency>
<groupId>io.opentelemetry</groupId>
<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

@@ -2,8 +2,12 @@ package com.magistr.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
@EnableScheduling
public class Application {
public static void main(String[] args) {

View File

@@ -1,50 +1,39 @@
package com.magistr.app.config;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* При запуске приложения инициализирует БД для каждого тенанта.
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
*/
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final TenantRoutingDataSource routingDataSource;
private final TenantConfigWatcher configWatcher;
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
public DataInitializer(TenantRoutingDataSource routingDataSource,
TenantConfigWatcher configWatcher) {
this.routingDataSource = routingDataSource;
this.configWatcher = configWatcher;
}
@Override
public void run(String... args) {
Optional<User> existing = userRepository.findByUsername("admin");
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
if (existing.isEmpty()) {
User admin = new User();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Created default admin user");
} else {
User admin = existing.get();
if (!passwordEncoder.matches("admin", admin.getPassword())) {
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Reset admin password (hash was invalid)");
} else {
log.info("Admin user already exists with correct password");
}
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
configWatcher.initDatabaseForTenant(tenant);
}
log.info("Database initialization complete");
}
}

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

@@ -0,0 +1,125 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
*
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
*/
@Service
public class ConfigMapUpdater {
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
private static final String CONFIGMAP_NAME = "tenants-config";
private final ObjectMapper objectMapper = new ObjectMapper();
private final boolean runningInK8s;
public ConfigMapUpdater() {
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
if (!runningInK8s) {
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
}
}
/**
* Обновляет ConfigMap tenants-config с новым списком тенантов.
* @return true если обновление успешно (или мы не в K8s)
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInK8s) {
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
return true;
}
try {
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
// Формируем JSON для тенантов
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(tenants);
// Strategic merge patch для ConfigMap
String patchBody = objectMapper.writeValueAsString(Map.of(
"data", Map.of("tenants.json", tenantsJson)
));
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
K8S_API_BASE, namespace, CONFIGMAP_NAME);
// Создаём HttpClient с отключённой проверкой сертификатов
// (внутри кластера используется self-signed CA)
HttpClient client = createInsecureClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/strategic-merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
return true;
} else {
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false;
}
} catch (Exception e) {
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
return false;
}
}
/**
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
*/
private HttpClient createInsecureClient() {
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder()
.sslContext(sslContext)
.build();
} catch (Exception e) {
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
return HttpClient.newHttpClient();
}
}
}

View File

@@ -0,0 +1,41 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Модель конфигурации тенанта (университета).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class TenantConfig {
private String name; // "ЮЗГУ", "МГУ"
private String domain; // "swsu", "mgu" (поддомен)
private String url; // "jdbc:postgresql://192.168.1.50:5432/magistr_db"
private String username;
private String password;
public TenantConfig() {}
public TenantConfig(String name, String domain, String url, String username, String password) {
this.name = name;
this.domain = domain;
this.url = url;
this.username = username;
this.password = password;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDomain() { return domain; }
public void setDomain(String domain) { this.domain = domain; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}

View File

@@ -0,0 +1,162 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.*;
import java.util.stream.Collectors;
/**
* Периодически перечитывает tenants.json (mounted ConfigMap).
* Если ConfigMap был обновлён через K8s API, этот компонент
* подхватит изменения и синхронизирует in-memory datasource'ы.
*
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
*/
@Component
public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private final TenantRoutingDataSource routingDataSource;
private final DataSource dataSource;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
private String lastConfigHash = "";
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
this.routingDataSource = routingDataSource;
this.dataSource = dataSource;
}
/**
* Каждые 30 секунд проверяет, изменился ли tenants.json.
*/
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() {
try {
File file = new File(tenantsConfigPath);
if (!file.exists()) return;
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
String hash = configHash(content);
if (hash.equals(lastConfigHash)) {
return; // Ничего не изменилось
}
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
lastConfigHash = hash;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
} catch (Exception e) {
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
}
}
/**
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
*/
public void refreshHash() {
try {
File file = new File(tenantsConfigPath);
if (file.exists()) {
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
lastConfigHash = configHash(content);
}
} catch (Exception e) {
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);
}
}
/**
* Синхронизирует in-memory тенантов с конфигом из файла.
*/
private void syncTenants(List<TenantConfig> newTenants) {
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
Set<String> newDomains = newTenants.stream()
.map(t -> t.getDomain().toLowerCase())
.collect(Collectors.toSet());
// Добавить новые тенанты
for (TenantConfig tenant : newTenants) {
String domain = tenant.getDomain().toLowerCase();
if (!current.containsKey(domain)) {
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant);
}
}
// Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) {
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
routingDataSource.removeTenant(existingDomain);
}
}
}
/**
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
* Если БД уже существует, но история Flyway пуста —
* делает baseline (считает V1_init.sql уже выполненным).
*/
public void initDatabaseForTenant(TenantConfig tenant) {
String domain = tenant.getDomain();
try {
TenantContext.setCurrentTenant(domain);
log.info("[{}] Запускаем миграции Flyway", domain);
// Получаем DataSource конкретно для этого тенанта
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
if (tenantDs == null) {
// Если ещё не resolve'нулся (первый запуск), берём обёртку
tenantDs = dataSource;
}
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
.dataSource(tenantDs)
.baselineOnMigrate(true)
.baselineVersion("1")
.load();
flyway.migrate();
log.info("[{}] Миграции Flyway успешно выполнены", domain);
} catch (Exception e) {
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
} finally {
TenantContext.clear();
}
}
}

View File

@@ -0,0 +1,22 @@
package com.magistr.app.config.tenant;
/**
* ThreadLocal хранилище текущего тенанта (домена).
* Устанавливается в TenantInterceptor на каждый HTTP-запрос.
*/
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static String getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(String tenant) {
CURRENT_TENANT.set(tenant);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}

View File

@@ -0,0 +1,137 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
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 jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* Конфигурация мультитенантного DataSource.
* Загружает тенанты из JSON-файла (mounted ConfigMap).
*
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
* как заглушку, чтобы Spring JPA мог инициализироваться.
*/
@Configuration
public class TenantDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
@Value("${spring.datasource.url:}")
private String defaultDbUrl;
@Value("${spring.datasource.username:}")
private String defaultDbUsername;
@Value("${spring.datasource.password:}")
private String defaultDbPassword;
@Bean
@Primary
public DataSource dataSource() {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// Загружаем тенантов из JSON (read-only ConfigMap mount)
List<TenantConfig> tenants = loadTenantsFromFile();
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
TenantConfig defaultTenant = new TenantConfig(
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
}
// Регистрируем тенантов
for (TenantConfig tenant : tenants) {
try {
routingDataSource.addTenant(tenant);
} catch (Exception e) {
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
}
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
if (routingDataSource.getTenantConfigs().isEmpty()) {
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
log.warn("Добавьте тенант через POST /api/database/tenants");
TenantConfig h2Fallback = new TenantConfig(
"H2 Placeholder", "default",
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
"sa", ""
);
routingDataSource.addTenant(h2Fallback);
}
return routingDataSource;
}
@Bean
public TenantRoutingDataSource tenantRoutingDataSource(DataSource dataSource) {
return (TenantRoutingDataSource) dataSource;
}
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.magistr.app.model");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setGenerateDdl(false);
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
em.setJpaVendorAdapter(vendorAdapter);
Map<String, Object> props = new HashMap<>();
props.put("hibernate.hbm2ddl.auto", "none");
props.put("hibernate.show_sql", "false");
em.setJpaPropertyMap(props);
return em;
}
@Bean
@Primary
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
log.info("Tenants config file not found: {}", tenantsConfigPath);
return new ArrayList<>();
}
try {
ObjectMapper mapper = new ObjectMapper();
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
return list;
} catch (IOException e) {
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,106 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Map;
import org.slf4j.MDC;
import io.opentelemetry.api.trace.Span;
/**
* Interceptor: извлекает поддомен из Host header и кладёт в TenantContext.
*
* Если тенант не настроен в TenantRoutingDataSource —
* сразу возвращает HTTP 404 (не допускает fallback на чужой тенант).
*
* Примеры:
* "swsu.zuev.company" → tenant = "swsu"
* "mgu.zuev.company" → tenant = "mgu"
* "localhost" → tenant = "default"
* "localhost:8080" → tenant = "default"
*
* API управления тенантами (/api/database/**) пропускается без проверки,
* чтобы администратор мог добавлять тенантов с любого домена.
*/
public class TenantInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class);
private TenantRoutingDataSource routingDataSource;
/**
* Устанавливается после создания бина (из TenantDataSourceConfig).
*/
public void setRoutingDataSource(TenantRoutingDataSource routingDataSource) {
this.routingDataSource = routingDataSource;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String host = request.getHeader("Host");
String tenant = resolveTenant(host);
String path = request.getRequestURI();
// API управления тенантами — всегда пропускаем
// (нужно чтобы админ мог добавить тенант даже если его домен не настроен)
if (path.startsWith("/api/database")) {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
return true;
}
// Проверяем, существует ли тенант
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
response.setStatus(404);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"error", "Тенант не найден",
"tenant", tenant,
"message", "Домен " + host + " не настроен. Обратитесь к администратору."
));
return false; // Останавливаем обработку запроса
}
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
MDC.remove("tenant.id");
}
private String resolveTenant(String host) {
if (host == null || host.isBlank()) {
return "default";
}
// Убираем порт (localhost:8080 → localhost)
String hostname = host.contains(":") ? host.substring(0, host.indexOf(':')) : host;
// localhost или IP → default
if ("localhost".equalsIgnoreCase(hostname) || hostname.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) {
return "default";
}
// Извлекаем первый поддомен: swsu.zuev.company → swsu
int firstDot = hostname.indexOf('.');
if (firstDot > 0) {
return hostname.substring(0, firstDot).toLowerCase();
}
return "default";
}
}

View File

@@ -0,0 +1,150 @@
package com.magistr.app.config.tenant;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* DataSource, который переключается между БД разных тенантов.
* На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext.
*/
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>();
private final Map<Object, Object> dataSources = new ConcurrentHashMap<>();
private boolean initialized = false;
public TenantRoutingDataSource() {
// Устанавливаем пустой map чтобы afterPropertiesSet не падал
setTargetDataSources(new HashMap<>());
setLenientFallback(false);
}
@Override
protected Object determineCurrentLookupKey() {
String tenant = TenantContext.getCurrentTenant();
if (tenant == null) {
// Нет HTTP контекста (JPA init, background tasks) — берём первый доступный
if (!dataSources.isEmpty()) {
return dataSources.keySet().iterator().next().toString();
}
return "default";
}
// HTTP запрос — возвращаем точный ключ тенанта
// Если тенанта нет — TenantInterceptor уже вернул 404
return tenant;
}
/**
* Добавляет тенант и создаёт для него HikariCP пул.
*/
public void addTenant(TenantConfig config) {
String domain = config.getDomain().toLowerCase();
HikariDataSource ds = createDataSource(config);
dataSources.put(domain, ds);
tenantConfigs.put(domain, config);
// Обновляем target data sources
setTargetDataSources(dataSources);
afterPropertiesSet();
initialized = true;
log.info("Added tenant '{}' -> {}", domain, config.getUrl());
}
/**
* Удаляет тенант и закрывает его пул соединений.
*/
public void removeTenant(String domain) {
domain = domain.toLowerCase();
Object removed = dataSources.remove(domain);
tenantConfigs.remove(domain);
if (removed instanceof HikariDataSource ds) {
ds.close();
log.info("Removed and closed tenant '{}'", domain);
}
setTargetDataSources(dataSources);
afterPropertiesSet();
}
/**
* Проверяет подключение к БД для указанного тенанта.
*/
public boolean testConnection(String domain) {
DataSource ds = (DataSource) dataSources.get(domain.toLowerCase());
if (ds == null) return false;
try (Connection conn = ds.getConnection()) {
return conn.isValid(5);
} catch (SQLException e) {
log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage());
return false;
}
}
/**
* Тестирует подключение по произвольным параметрам (без регистрации тенанта).
*/
public String testExternalConnection(String url, String username, String password) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
ds.setMaximumPoolSize(1);
ds.setConnectionTimeout(5000);
try (Connection conn = ds.getConnection()) {
if (conn.isValid(5)) {
return "OK";
}
return "Подключение не валидно";
} catch (Exception e) {
return e.getMessage();
} finally {
ds.close();
}
}
public Map<String, TenantConfig> getTenantConfigs() {
return tenantConfigs;
}
public boolean hasTenant(String domain) {
return tenantConfigs.containsKey(domain.toLowerCase());
}
public boolean isInitialized() {
return initialized && !dataSources.isEmpty();
}
private HikariDataSource createDataSource(TenantConfig config) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(config.getUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.setPoolName("tenant-" + config.getDomain());
ds.setMaximumPoolSize(10);
ds.setMinimumIdle(2);
ds.setConnectionTimeout(10000);
ds.setIdleTimeout(300000);
ds.setMaxLifetime(600000);
// Не падать при инициализации если БД недоступна
ds.setInitializationFailTimeout(-1);
return ds;
}
}

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,34 +29,174 @@ 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() ||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
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));
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

@@ -0,0 +1,184 @@
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.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* API управления подключениями к базам данных (тенантами).
* Доступно только для ADMIN.
*
* При добавлении/удалении тенанта:
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
*/
@RestController
@RequestMapping("/api/database")
@RequireRoles({Role.ADMIN})
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;
private final ConfigMapUpdater configMapUpdater;
private final TenantConfigWatcher configWatcher;
public DatabaseController(TenantRoutingDataSource routingDataSource,
ConfigMapUpdater configMapUpdater,
TenantConfigWatcher configWatcher) {
this.routingDataSource = routingDataSource;
this.configMapUpdater = configMapUpdater;
this.configWatcher = configWatcher;
}
/**
* Статус текущего подключения (по домену запроса).
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getStatus() {
String currentTenant = TenantContext.getCurrentTenant();
boolean connected = routingDataSource.testConnection(currentTenant);
TenantConfig config = routingDataSource.getTenantConfigs().get(currentTenant);
Map<String, Object> result = new HashMap<>();
result.put("tenant", currentTenant);
result.put("connected", connected);
result.put("configured", config != null);
if (config != null) {
result.put("name", config.getName());
result.put("url", config.getUrl());
}
return ResponseEntity.ok(result);
}
/**
* Список всех тенантов.
*/
@GetMapping("/tenants")
public ResponseEntity<List<Map<String, Object>>> getTenants() {
List<Map<String, Object>> result = new ArrayList<>();
for (TenantConfig config : routingDataSource.getTenantConfigs().values()) {
Map<String, Object> tenant = new HashMap<>();
tenant.put("name", config.getName());
tenant.put("domain", config.getDomain());
tenant.put("url", config.getUrl());
tenant.put("username", config.getUsername());
tenant.put("connected", routingDataSource.testConnection(config.getDomain()));
result.add(tenant);
}
return ResponseEntity.ok(result);
}
/**
* Добавить новый тенант.
*/
@PostMapping("/tenants")
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
Map<String, Object> result = new HashMap<>();
if (config.getDomain() == null || config.getDomain().isBlank()) {
result.put("success", false);
result.put("message", "Домен не может быть пустым");
return ResponseEntity.badRequest().body(result);
}
if (config.getUrl() == null || config.getUrl().isBlank()) {
result.put("success", false);
result.put("message", "URL базы данных не может быть пустым");
return ResponseEntity.badRequest().body(result);
}
if (routingDataSource.hasTenant(config.getDomain())) {
routingDataSource.removeTenant(config.getDomain());
}
try {
// 1. Добавить в in-memory (мгновенно на этом поде)
routingDataSource.addTenant(config);
// 2. Инициализировать БД (init.sql) если нужно
configWatcher.initDatabaseForTenant(config);
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
persistToConfigMap();
result.put("success", true);
result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "Ошибка: " + e.getMessage());
return ResponseEntity.internalServerError().body(result);
}
}
/**
* Удалить тенант.
*/
@DeleteMapping("/tenants/{domain}")
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
Map<String, Object> result = new HashMap<>();
if (!routingDataSource.hasTenant(domain)) {
result.put("success", false);
result.put("message", "Тенант '" + domain + "' не найден");
return ResponseEntity.status(404).body(result);
}
routingDataSource.removeTenant(domain);
persistToConfigMap();
result.put("success", true);
result.put("message", "Тенант '" + domain + "' удалён");
return ResponseEntity.ok(result);
}
/**
* Тест подключения к произвольной БД.
*/
@PostMapping("/test")
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody Map<String, String> params) {
String url = params.get("url");
String username = params.get("username");
String password = params.get("password");
Map<String, Object> result = new HashMap<>();
if (url == null || url.isBlank()) {
result.put("success", false);
result.put("message", "URL не указан");
return ResponseEntity.badRequest().body(result);
}
String testResult = routingDataSource.testExternalConnection(url, username, password);
boolean success = "OK".equals(testResult);
result.put("success", success);
result.put("message", success ? "Подключение успешно!" : testResult);
return ResponseEntity.ok(result);
}
/**
* Сохраняет текущий список тенантов в K8s ConfigMap.
*/
private void persistToConfigMap() {
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
if (ok) {
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
}
}
}

View File

@@ -0,0 +1,185 @@
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;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/departments")
@RequireRoles({Role.ADMIN})
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
private final DepartmentRepository departmentRepository;
public DepartmentController(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
@GetMapping
@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 = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
d.getDepartmentName(),
d.getDepartmentCode()
))
.toList();
logger.info("Получено {} кафедр", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createDepartment(@RequestBody CreateDepartmentRequest request) {
logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode());
try {
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){
String errorMessage = "Название кафедры обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) {
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));
}
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) {
String errorMessage = "Кафедра с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department department = new Department();
department.setDepartmentName(request.getDepartmentName());
department.setDepartmentCode(request.getDepartmentCode());
departmentRepository.save(department);
logger.info("Кафедра успешно создана с ID: {}", department.getId());
return ResponseEntity.ok(
new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
}
}
@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();
}
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,83 +1,429 @@
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() {
return groupRepository.findAll().stream()
.map(g -> new GroupResponse(
g.getId(),
g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(),
g.getEducationForm().getName()))
.toList();
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
.toList();
logger.info("Получено {} групп", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Группы для указанной кафедры не найдены");
}
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
.toList();
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)
.body("Произошла ошибка при получении списка групп");
}
}
@PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
try {
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.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
}
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
}
@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);
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
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 ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
return validationError("Численность группы обязательна");
}
if (request.getEducationFormId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
return validationError("Форма обучения обязательна");
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
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;
}
StudentGroup group = new StudentGroup();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
groupRepository.save(group);
private ResponseEntity<?> validationError(String errorMessage) {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
return ResponseEntity.ok(new GroupResponse(
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.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()
);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
@GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
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

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

@@ -0,0 +1,299 @@
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;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
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,
SpecialtyProfileRepository specialtyProfileRepository) {
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
}
@GetMapping
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
s.getSpecialityName(),
s.getSpecialityCode()
))
.toList();
logger.info("Получено {} специальностей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createSpeciality(@RequestBody CreateSpecialityRequest request) {
logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode());
try {
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
String errorMessage = "Название специальности обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) {
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));
}
if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) {
String errorMessage = "Специальность с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Speciality speciality = new Speciality();
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());
return ResponseEntity.ok(
new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
}
}
@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();
}
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,16 @@
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;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -10,8 +19,11 @@ import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
@RequireRoles({Role.ADMIN})
public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) {
@@ -19,33 +31,129 @@ public class SubjectController {
}
@GetMapping
public List<Subject> getAllSubjects() {
return subjectRepository.findAll();
@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 = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> response = subjects.stream()
.map(s -> new Subject(
s.getId(),
s.getName(),
s.getCode(),
s.getDepartmentId()
))
.toList();
logger.info("Получено {} дисциплин", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e);
throw e;
}
}
@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.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Дисциплины для указанной кафедры не найдены");
}
logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId);
return ResponseEntity.ok(subjects);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка дисциплин");
}
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
}
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
request.getName(), request.getCode(), request.getDepartmentId());
Subject subject = new Subject();
subject.setName(name.trim());
subjectRepository.save(subject);
try {
if (request.getName() == null || request.getName().isBlank()) {
String errorMessage = "Название обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
String errorMessage = "Дисциплина с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getCode() == null || request.getCode().isBlank()) {
String errorMessage = "Код дисциплины обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
return ResponseEntity.ok(subject);
Subject subject = new Subject();
subject.setName(request.getName());
subject.setCode(request.getCode());
subject.setDepartmentId(request.getDepartmentId());
subjectRepository.save(subject);
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
return ResponseEntity.ok(
new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
)
);
} 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<?> deleteSubject(@PathVariable Long id) {
if (!subjectRepository.existsById(id)) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(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,59 +1,151 @@
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.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) {
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() {
return userRepository.findAll().stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
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() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
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){
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.getUsername() == null || request.getUsername().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
if (request == null) {
String errorMessage = "Данные пользователя обязательны";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
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()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
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 (Exception e) {
} catch (IllegalArgumentException | NullPointerException e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
@@ -61,17 +153,181 @@ public class UserController {
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);
}
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(toUserResponse(user));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
User user = userRepository.findById(id).orElse(null);
if (user == null) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
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();
}
userRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
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

@@ -0,0 +1,25 @@
package com.magistr.app.dto;
public class CreateDepartmentRequest {
private String departmentName;
private Long departmentCode;
public CreateDepartmentRequest() {}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

View File

@@ -5,6 +5,11 @@ public class CreateGroupRequest {
private String name;
private Long groupSize;
private Long educationFormId;
private Long departmentId;
private Integer yearStartStudy;
private Long specialtyId;
private Long specialtyProfileId;
private Long specialityCode;
public String getName() {
return name;
@@ -29,4 +34,48 @@ public class CreateGroupRequest {
public void setEducationFormId(Long educationFormId) {
this.educationFormId = educationFormId;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public void setYearStartStudy(Integer yearStartStudy) {
this.yearStartStudy = yearStartStudy;
}
public Long getSpecialityCode() {
return specialityCode;
}
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

@@ -0,0 +1,25 @@
package com.magistr.app.dto;
public class CreateSpecialityRequest {
private String specialityName;
private String specialityCode;
public CreateSpecialityRequest() {}
public String getSpecialityName() {
return specialityName;
}
public void setSpecialityName(String specialityName) {
this.specialityName = specialityName;
}
public String getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(String specialityCode) {
this.specialityCode = specialityCode;
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
public class CreateSubjectRequest {
private Long id;
private String name;
private String code;
private Long departmentId;
public CreateSubjectRequest() {};
public CreateSubjectRequest(Long id, String name, String code, Long departmentId) {
this.id = id;
this.name = name;
this.code = code;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

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

View File

@@ -5,6 +5,9 @@ public class CreateUserRequest {
private String username;
private String password;
private String role;
private String fullName;
private String jobTitle;
private Long departmentId;
public CreateUserRequest() {
}
@@ -32,4 +35,28 @@ public class CreateUserRequest {
public void setRole(String role) {
this.role = role;
}
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;
}
}

View File

@@ -0,0 +1,38 @@
package com.magistr.app.dto;
public class DepartmentResponse {
private Long id;
private String departmentName;
private Long departmentCode;
public DepartmentResponse(Long id, String departmentName, Long departmentCode) {
this.id = id;
this.departmentName = departmentName;
this.departmentCode = departmentCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

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;
@@ -7,13 +10,56 @@ public class GroupResponse {
private Long groupSize;
private Long educationFormId;
private String educationFormName;
private Long departmentId;
private Integer yearStartStudy;
private Integer course;
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) {
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.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() {
@@ -35,4 +81,60 @@ public class GroupResponse {
public String getEducationFormName() {
return educationFormName;
}
public Long getDepartmentId() {
return departmentId;
}
public Integer getCourse() {
return course;
}
public Integer getSemester() {
return semester;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public Long getSpecialityCode() {
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

@@ -7,16 +7,24 @@ public class LoginResponse {
private String token;
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) {
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() {
@@ -58,4 +66,20 @@ public class LoginResponse {
public void setRedirect(String redirect) {
this.redirect = redirect;
}
public Long getDepartmentId() {
return departmentId;
}
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

@@ -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,38 @@
package com.magistr.app.dto;
public class SpecialityResponse {
private Long id;
private String specialityName;
private String specialityCode;
public SpecialityResponse(Long id, String specialityName, String specialityCode) {
this.id = id;
this.specialityName = specialityName;
this.specialityCode = specialityCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSpecialityName() {
return specialityName;
}
public void setSpecialityName(String specialityName) {
this.specialityName = specialityName;
}
public String getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(String specialityCode) {
this.specialityCode = specialityCode;
}
}

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,35 @@
package com.magistr.app.dto;
public class SubjectResponse {
private Long id;
private String name;
private String code;
private Long departmentId;
public SubjectResponse() {};
public SubjectResponse(Long id, String name, String code, Long departmentId) {
this.id = id;
this.name = name;
this.code = code;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getCode() {
return code;
}
public Long getDepartmentId() {
return departmentId;
}
}

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

@@ -1,41 +1,85 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserResponse {
private Long id;
private String username;
private String role;
private String fullName;
private String jobTitle;
private String departmentName;
private Long departmentId;
private String status;
public UserResponse() {
}
public UserResponse(Long id, String username, String role) {
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
this.id = id;
this.username = username;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentName = departmentName;
}
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
this.id = id;
this.username = username;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentId = departmentId;
}
public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) {
this.id = id;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
public String getFullName() {
return fullName;
}
public String getJobTitle() {
return jobTitle;
}
public String getDepartmentName() {
return departmentName;
}
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;
}

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