92 Commits

Author SHA1 Message Date
Zuev
e82ed69639 тестовая реализация подсчёта курса и семестра 2026-03-31 13:54:53 +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
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
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
ProstoDenya01
05fcf86e32 Поправил модалку с расписанием препода 2026-03-15 15:51:29 +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
8df736ae36 Кривая модалка занятий по teacherId добавлена, требует доработки, также код users.js требует унификации+оптимизации(новая модалка вкинута в конец) 2026-03-13 03:12:48 +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
24caa148e1 Модалка перенесена в шапку, частично настроены стили 2026-03-13 02:00:49 +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
ProstoDenya01
03eaf6ab13 Добавил для групп поле численности.
В модалку на UI добавил отображение численности групп и вместимости аудиторий, проверку на доступность и вместимость.
2026-03-12 14:45:25 +03:00
1b0a6c86ff Merge pull request 'Доделал модалку на создание занятий. Добавил поля выбора аудитории, типа и формата занятий.' (#6) from Create-Lesson into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 12s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m13s
Reviewed-on: #6
2026-03-11 09:45:46 +00:00
ProstoDenya01
0216dfaa40 Доделал модалку на создание занятий. Добавил поля выбора аудитории, типа и формата занятий. 2026-03-11 12:43:42 +03:00
Zuev
7e0e2cdfc5 fix: increase rollout timeout for backend to 300s
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 1m15s
2026-03-11 02:58:49 +03:00
a6ee024935 Merge pull request 'Create-Lesson' (#5) from Create-Lesson into main
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 27s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 2m6s
Reviewed-on: #5
2026-03-10 23:51:53 +00:00
Zuev
6d003f5fa8 fix: install kubectl in deploy-to-k8s job
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 1m12s
2026-03-11 02:49:33 +03:00
8deba5cc3d Merge pull request 'feat: add CD step to docker-build workflow' (#4) from Zuev into main
Some checks failed
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 16s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 1s
Reviewed-on: #4
2026-03-10 23:30:16 +00:00
Zuev
11ef481269 feat: add CD step to docker-build workflow 2026-03-11 02:28:52 +03:00
494a671c3d Merge pull request 'fix: resolve labels property issue in docker-build workflow' (#3) from Zuev into main
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 11s
Reviewed-on: #3
2026-03-10 22:12:01 +00:00
Zuev
c8570d3cf8 fix: resolve labels property issue in docker-build workflow 2026-03-11 01:10:00 +03:00
5104e36d7d Merge pull request 'Update GitHub Actions versions to latest' (#2) from Zuev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 2m29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Reviewed-on: #2
2026-03-09 23:23:50 +00:00
Zuev
8e540940f7 Update GitHub Actions versions to latest 2026-03-10 02:22:48 +03:00
e8b1d77117 Merge pull request 'Zuev' (#1) from Zuev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 7m10s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 15s
Reviewed-on: #1
2026-03-09 22:56:57 +00:00
Zuev
41dec9c772 update docker-build.yaml 2026-03-10 01:51:33 +03:00
ProstoDenya01
01ea7a8dc1 Обновил init.sql, добавил одного преподавателя и несколько занятий при инициализации БД
Добавил методы на удаление и обновления занятий
2026-03-06 17:14:29 +03:00
ProstoDenya01
9bd21757d6 Доделал таблицу вывода занятий на FE 2026-03-04 23:31:42 +03:00
ProstoDenya01
7a729a782d тест 2026-03-04 22:57:56 +03:00
ProstoDenya01
be35733e4d Merge remote-tracking branch 'origin/Zuev' into Create-Lesson 2026-03-04 22:57:08 +03:00
ProstoDenya01
169f7435b1 Добавил новые поля в создание занятия и получение общего списка 2026-03-04 22:49:58 +03:00
Zuev
2563c769de Реализована фильтрация столбцов и улучшена сортировка в расписании 2026-03-04 22:46:36 +03:00
ProstoDenya01
47039ee878 Изменил метод на получение общего списка занятий. Внёс небольшие правки в FE для странички с расписанием 2026-03-03 17:28:20 +03:00
alekan
88f1abfe25 Создание вкладки "Расписание занятий" 2026-03-03 00:02:07 +03:00
alekan
2004766855 Создание кнопки "Добавить занятие" с модальным окном с выбором дня,недели и тд. 2026-03-02 23:27:20 +03:00
ProstoDenya01
0e03dcb2d6 Поправил комментарий в application.properties 2026-03-02 16:27:20 +03:00
ProstoDenya01
438a16c383 Исправил все известные баги в запросе на создание занятия для пользователя.
Написал запрос на получение списка занятий по конкретному ID пользователя.
Добавил логирование для LessonsController
2026-03-02 16:24:22 +03:00
Zuev
9d06c69e2b chore: update .gitignore 2026-02-26 01:29:13 +03:00
Zuev
684273de50 Fix CSS appearance warning and format JS 2026-02-26 00:39:48 +03:00
Zuev
772b110762 Refactor admin frontend into modular SPA 2026-02-26 00:27:10 +03:00
Zuev
18f47c6d3d fix: adapt init.sql lessons table to match Create-Lesson branch entity 2026-02-25 23:27:17 +03:00
ProstoDenya01
007b4fb619 Поправил id дисциплины 2026-02-25 23:13:49 +03:00
147 changed files with 13868 additions and 2739 deletions

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) в разных генерациях кода.
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.

View File

@@ -0,0 +1,97 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
tags:
- 'v*'
env:
REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea
BACKEND_IMAGE: zuev/magistr-backend
FRONTEND_IMAGE: zuev/magistr-frontend
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }} # Нужно создать секрет ZUEV_TOKEN в настройках репозитория (Personal Access Token)
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
deploy-to-k8s:
needs: [build-and-push-backend, build-and-push-frontend]
runs-on: ubuntu-latest
steps:
- name: Create kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Trigger Kubernetes Rollout
run: |
# Перезапускаем поды, чтобы они скачали свежий :main образ
kubectl rollout restart deployment backend frontend -n magistr
# Ждём успешного обновления (5 минут на backend из-за Spring Boot)
kubectl rollout status deployment/frontend -n magistr --timeout=120s
kubectl rollout status deployment/backend -n magistr --timeout=300s

15
.gitignore vendored
View File

@@ -1,18 +1,13 @@
# Игнорируем данные БД (но не init-скрипты)
db/data/ db/data/
# Игнорируем секреты
.env .env
!GEMINI.md
!AGENTS.md
# Игнорируем системные папки IDE (если редактируете с ПК)
.idea/
.vscode/
*.DS_Store
.agent/
# Игнорируем временные файлы сборки (на будущее) # Игнорируем временные файлы сборки (на будущее)
backend/target/ backend/target/
backend/build/ backend/build/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
.idea/
.vscode/
*.DS_Store
skills-lock.json

89
AGENTS.md Executable file
View File

@@ -0,0 +1,89 @@
# 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)
│ ├── 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) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |

9
backend/Dockerfile Normal file → Executable file
View File

@@ -4,9 +4,16 @@ COPY pom.xml .
RUN mvn dependency:go-offline -B RUN mvn dependency:go-offline -B
COPY src ./src COPY src ./src
RUN mvn package -DskipTests -B 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 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 WORKDIR /app
COPY --from=build /app/target/app.jar app.jar COPY --from=build /app/target/app.jar app.jar
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]

20
backend/pom.xml Normal file → Executable file
View File

@@ -32,6 +32,12 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<!-- Flyway Database Migrations -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
@@ -43,6 +49,20 @@
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId> <artifactId>spring-security-crypto</artifactId>
</dependency> </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>
</dependencies> </dependencies>
<build> <build>

6
backend/src/main/java/com/magistr/app/Application.java Normal file → Executable file
View File

@@ -2,8 +2,12 @@ package com.magistr.app;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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 class Application {
public static void main(String[] args) { public static void main(String[] args) {

2
backend/src/main/java/com/magistr/app/README.md Normal file → Executable file
View File

@@ -1 +1 @@
КОММИТ12 тест

View File

View File

@@ -1,50 +1,39 @@
package com.magistr.app.config; package com.magistr.app.config;
import com.magistr.app.model.Role; import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.model.User; import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.repository.UserRepository; import com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Optional; /**
* При запуске приложения инициализирует БД для каждого тенанта.
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
*/
@Component @Component
public class DataInitializer implements CommandLineRunner { public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepository; private final TenantRoutingDataSource routingDataSource;
private final BCryptPasswordEncoder passwordEncoder; private final TenantConfigWatcher configWatcher;
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { public DataInitializer(TenantRoutingDataSource routingDataSource,
this.userRepository = userRepository; TenantConfigWatcher configWatcher) {
this.passwordEncoder = passwordEncoder; this.routingDataSource = routingDataSource;
this.configWatcher = configWatcher;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
Optional<User> existing = userRepository.findByUsername("admin"); log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
if (existing.isEmpty()) { for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
User admin = new User(); configWatcher.initDatabaseForTenant(tenant);
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");
}
} }
log.info("Database initialization complete");
} }
} }

View File

@@ -0,0 +1,127 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
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("Not running in K8s — ConfigMap updates will be skipped");
}
}
/**
* Обновляет ConfigMap tenants-config с новым списком тенантов.
* @return true если обновление успешно (или мы не в K8s)
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInK8s) {
log.warn("Not in K8s, skipping ConfigMap update");
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 '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
return true;
} else {
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false;
}
} catch (Exception e) {
log.error("Error updating 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("Failed to create insecure client, using default: {}", 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,156 @@
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.BufferedReader;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.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 = Integer.toHexString(content.hashCode());
if (hash.equals(lastConfigHash)) {
return; // Ничего не изменилось
}
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
lastConfigHash = hash;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
} catch (Exception e) {
log.error("Error watching tenants config: {}", e.getMessage());
}
}
/**
* Обновляет хеш конфига (вызывается после ручного обновления 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 = Integer.toHexString(content.hashCode());
}
} catch (Exception e) {
log.warn("Failed to refresh config hash: {}", e.getMessage());
}
}
/**
* Синхронизирует 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("Adding new tenant '{}' from ConfigMap update", domain);
routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant);
}
}
// Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) {
log.info("Removing tenant '{}' (no longer in 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("[{}] Starting Flyway migrations...", 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 migrations completed successfully", domain);
} catch (Exception e) {
log.error("[{}] Flyway migration failed: {}", 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,156 @@
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 org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
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 implements WebMvcConfigurer {
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("Failed to add tenant '{}': {}", 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);
}
@org.springframework.context.annotation.Lazy
@org.springframework.beans.factory.annotation.Autowired
private TenantRoutingDataSource tenantRoutingDataSource;
@Bean
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
TenantInterceptor interceptor = new TenantInterceptor();
interceptor.setRoutingDataSource(routingDataSource);
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
}
private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
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("Failed to read tenants config: {}", 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

@@ -38,14 +38,15 @@ public class AuthController {
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) { !passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
return ResponseEntity return ResponseEntity
.status(401) .status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null)); .body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
} }
User user = userOpt.get(); User user = userOpt.get();
String token = UUID.randomUUID().toString(); String token = UUID.randomUUID().toString();
String roleName = user.getRole().name(); String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect)); return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
} }
} }

View File

@@ -0,0 +1,181 @@
package com.magistr.app.controller;
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 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")
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,106 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department;
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")
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
private final DepartmentRepository departmentRepository;
public DepartmentController(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
@GetMapping
public List<Department> getAllDepartments() {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = departmentRepository.findAll();
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()));
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(id)) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
departmentRepository.deleteById(id);
logger.info("Кафедра с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
}
}

View File

@@ -6,6 +6,9 @@ import com.magistr.app.model.EducationForm;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -17,6 +20,8 @@ import java.util.Optional;
@RequestMapping("/api/groups") @RequestMapping("/api/groups")
public class GroupController { public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final EducationFormRepository educationFormRepository; private final EducationFormRepository educationFormRepository;
@@ -28,50 +33,139 @@ public class GroupController {
@GetMapping @GetMapping
public List<GroupResponse> getAllGroups() { public List<GroupResponse> getAllGroups() {
return groupRepository.findAll().stream() logger.info("Получен запрос на получение всех групп");
.map(g -> new GroupResponse(
try {
List<StudentGroup> groups = groupRepository.findAll();
List<GroupResponse> response = groups.stream()
.map(g -> new GroupResponse(
g.getId(), g.getId(),
g.getName(), g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(), g.getEducationForm().getId(),
g.getEducationForm().getName())) g.getEducationForm().getName(),
.toList(); g.getDepartmentId(),
g.getEnrollmentYear(),
g.getCourse(),
g.getSemester(),
g.getSpecialityCode()
))
.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);
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Группы для указанной кафедры не найдены");
}
logger.info("Найдено {} групп для кафедры с ID - {}", groups.size(), departmentId);
return ResponseEntity.ok(groups);
} catch (Exception e) {
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка групп");
}
} }
@PostMapping @PostMapping
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) { public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
if (request.getName() == null || request.getName().isBlank()) { logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, enrollmentYear = {}",
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно")); request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getEnrollmentYear());
} try {
if (groupRepository.findByName(request.getName().trim()).isPresent()) { if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует")); String errorMessage = "Название группы обязательно";
} logger.error("Ошибка валидации: {}", errorMessage);
if (request.getEducationFormId() == null) { return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна")); }
} if (groupRepository.findByName(request.getName().trim()).isPresent()) {
String errorMessage = "Группа с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupSize() == null) {
String errorMessage = "Численность группы обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getEducationFormId() == null) {
String errorMessage = "Форма обучения обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getEnrollmentYear() == null || request.getEnrollmentYear() == 0) {
String errorMessage = "Год начала обучения обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId()); Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) { if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); 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.setEnrollmentYear(request.getEnrollmentYear());
group.setSpecialityCode(request.getSpecialityCode());
groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(new GroupResponse(
group.getId(),
group.getName(),
group.getGroupSize(),
group.getEducationForm().getId(),
group.getEducationForm().getName(),
group.getDepartmentId(),
group.getEnrollmentYear(),
group.getCourse(),
group.getSemester(),
group.getSpecialityCode()));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
} }
StudentGroup group = new StudentGroup();
group.setName(request.getName().trim());
group.setEducationForm(efOpt.get());
groupRepository.save(group);
return ResponseEntity.ok(new GroupResponse(
group.getId(),
group.getName(),
group.getEducationForm().getId(),
group.getEducationForm().getName()));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) { public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id);
if (!groupRepository.existsById(id)) { if (!groupRepository.existsById(id)) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
groupRepository.deleteById(id); groupRepository.deleteById(id);
logger.info("Группа с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Группа удалена")); return ResponseEntity.ok(Map.of("message", "Группа удалена"));
} }
} }

View File

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

View File

@@ -0,0 +1,106 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.model.Speciality;
import com.magistr.app.repository.SpecialtiesRepository;
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")
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
private final SpecialtiesRepository specialtiesRepository;
public SpecialityController(SpecialtiesRepository specialtiesRepository) {
this.specialtiesRepository = specialtiesRepository;
}
@GetMapping
public List<Speciality> getAllSpecialties() {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = specialtiesRepository.findAll();
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);
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()));
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(id)) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
specialtiesRepository.deleteById(id);
logger.info("Специальность с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Специальнсть удалена"));
}
}

View File

@@ -1,7 +1,13 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -12,6 +18,8 @@ import java.util.Map;
@RequestMapping("/api/subjects") @RequestMapping("/api/subjects")
public class SubjectController { public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) { public SubjectController(SubjectRepository subjectRepository) {
@@ -20,32 +28,105 @@ public class SubjectController {
@GetMapping @GetMapping
public List<Subject> getAllSubjects() { public List<Subject> getAllSubjects() {
return subjectRepository.findAll(); logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = subjectRepository.findAll();
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}")
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
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 @PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) { public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
String name = request.get("name"); logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
if (name == null || name.isBlank()) { request.getName(), request.getCode(), request.getDepartmentId());
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
}
Subject subject = new Subject(); try {
subject.setName(name.trim()); if (request.getName() == null || request.getName().isBlank()) {
subjectRepository.save(subject); 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}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) { public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
if (!subjectRepository.existsById(id)) { if (!subjectRepository.existsById(id)) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
subjectRepository.deleteById(id); subjectRepository.deleteById(id);
logger.info("Дисциплина с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
} }
} }

View File

@@ -1,67 +0,0 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateLessonRequest;
import com.magistr.app.dto.LessonResponse;
import com.magistr.app.model.Lesson;
import com.magistr.app.repository.LessonRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users/test")
public class TestController {
private final LessonRepository lessonRepository;
public TestController(LessonRepository lessonRepository) {
this.lessonRepository = lessonRepository;
}
@PostMapping("/create")
public ResponseEntity<?> createLesson(@RequestBody CreateLessonRequest request) {
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID преподавателя обязателен"));
}
if (request.getGroupId() == null || request.getGroupId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID группы обязателен"));
}
if (request.getDisciplineId() == null || request.getDisciplineId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID предмета обязателен"));
}
if (request.getDay() == null || request.getDay().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Выбор дня обязателен"));
}
if (request.getWeek() == null || request.getWeek().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Выбор недели обязателен"));
}
if (request.getTime() == null || request.getTime().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Время обязательно"));
}
Lesson lesson = new Lesson();
lesson.setTeacherId(request.getTeacherId());
lesson.setDisciplineId(request.getDisciplineId());
lesson.setGroupId(request.getGroupId());
lesson.setDay(request.getDay());
lesson.setWeek(request.getWeek());
lesson.setTime(request.getTime());
lessonRepository.save(lesson);
return ResponseEntity.ok(new LessonResponse(lesson.getId(), lesson.getDay(), lesson.getWeek(), lesson.getTime()));
}
@GetMapping
public List<LessonResponse> getAllLessons() {
return lessonRepository.findAll().stream()
.map(l -> new LessonResponse(l.getId(), l.getTeacherId(), l.getDisciplineId(), l.getDay(), l.getWeek(), l.getTime()))
.toList();
}
@GetMapping("/ping")
public String ping() {
return "pong";
}
}

View File

@@ -2,9 +2,15 @@ package com.magistr.app.controller;
import com.magistr.app.dto.CreateUserRequest; import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
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.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -16,62 +22,182 @@ import java.util.Map;
@RequestMapping("/api/users") @RequestMapping("/api/users")
public class UserController { public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
} }
@GetMapping @GetMapping
public List<UserResponse> getAllUsers() { public List<UserResponse> getAllUsers() {
return userRepository.findAll().stream() logger.info("Получен запрос на получение всех пользователей");
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name())) try {
.toList(); List<User> users = userRepository.findAll();
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
throw e;
}
} }
@GetMapping("/teachers") @GetMapping("/teachers")
public List<UserResponse> getTeachers() { public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream() logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
.toList(); try {
List<User> users = userRepository.findByRole(Role.TEACHER);
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
throw e;
}
}
@GetMapping("/teachers/{departmentId}")
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
try {
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Преподаватели для указанной кафедры не найдены");
}
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
List<UserResponse> userResponses = users.stream()
.map( user -> {
return new UserResponse(
user.getId(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
}).toList();
return ResponseEntity.ok(userResponses);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка преподавателей");
}
} }
@PostMapping @PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) { logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
}
Role role;
try { try {
role = Role.valueOf(request.getRole()); if (request.getUsername() == null || request.getUsername().isBlank()) {
} catch (Exception e) { String errorMessage = "Имя пользователя обязательно";
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
String errorMessage = "Пароль минимум 4 символа";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
String errorMessage = "Пользователь уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getFullName() == null || request.getFullName().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
request.setJobTitle("Не указано");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (Exception e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
user.setFullName(request.getFullName());
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
} catch (Exception e ) {
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
} }
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
userRepository.save(user);
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) { public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
if (!userRepository.existsById(id)) { if (!userRepository.existsById(id)) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
userRepository.deleteById(id); userRepository.deleteById(id);
logger.info("Пользователь с ID - {} успешно удалён", id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён")); return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
} }
} }

View File

View File

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

@@ -3,7 +3,11 @@ package com.magistr.app.dto;
public class CreateGroupRequest { public class CreateGroupRequest {
private String name; private String name;
private Long groupSize;
private Long educationFormId; private Long educationFormId;
private Long departmentId;
private Integer enrollmentYear;
private Long specialityCode;
public String getName() { public String getName() {
return name; return name;
@@ -13,6 +17,14 @@ public class CreateGroupRequest {
this.name = name; this.name = name;
} }
public Long getGroupSize() {
return groupSize;
}
public void setGroupSize(Long groupSize) {
this.groupSize = groupSize;
}
public Long getEducationFormId() { public Long getEducationFormId() {
return educationFormId; return educationFormId;
} }
@@ -20,4 +32,28 @@ public class CreateGroupRequest {
public void setEducationFormId(Long educationFormId) { public void setEducationFormId(Long educationFormId) {
this.educationFormId = educationFormId; this.educationFormId = educationFormId;
} }
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Integer getEnrollmentYear() {
return enrollmentYear;
}
public void setEnrollmentYear(Integer enrollmentYear) {
this.enrollmentYear = enrollmentYear;
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
}
} }

View File

@@ -4,7 +4,10 @@ public class CreateLessonRequest {
private Long teacherId; private Long teacherId;
private Long groupId; private Long groupId;
private Long disciplineId; private Long subjectId;
private String lessonFormat;
private String typeLesson;
private Long classroomId;
private String day; private String day;
private String week; private String week;
private String time; private String time;
@@ -28,12 +31,36 @@ public class CreateLessonRequest {
this.groupId = groupId; this.groupId = groupId;
} }
public Long getDisciplineId() { public Long getSubjectId() {
return disciplineId; return subjectId;
} }
public void setDisciplineId(Long disciplineId) { public void setSubjectId(Long subjectId) {
this.disciplineId= disciplineId; 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() { public String getDay() {

View File

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

View File

@@ -0,0 +1,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

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

@@ -4,14 +4,29 @@ public class GroupResponse {
private Long id; private Long id;
private String name; private String name;
private Long groupSize;
private Long educationFormId; private Long educationFormId;
private String educationFormName; private String educationFormName;
private Long departmentId;
private Integer enrollmentYear;
private Integer course;
private Integer semester;
private Long specialityCode;
public GroupResponse(Long id, String name, Long educationFormId, String educationFormName) { public GroupResponse(Long id, String name, Long groupSize, Long educationFormId,
String educationFormName, Long departmentId,
Integer enrollmentYear, Integer course, Integer semester,
Long specialityCode) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.groupSize = groupSize;
this.educationFormId = educationFormId; this.educationFormId = educationFormId;
this.educationFormName = educationFormName; this.educationFormName = educationFormName;
this.departmentId = departmentId;
this.enrollmentYear = enrollmentYear;
this.course = course;
this.semester = semester;
this.specialityCode = specialityCode;
} }
public Long getId() { public Long getId() {
@@ -22,6 +37,10 @@ public class GroupResponse {
return name; return name;
} }
public Long getGroupSize() {
return groupSize;
}
public Long getEducationFormId() { public Long getEducationFormId() {
return educationFormId; return educationFormId;
} }
@@ -29,4 +48,24 @@ public class GroupResponse {
public String getEducationFormName() { public String getEducationFormName() {
return educationFormName; return educationFormName;
} }
public Long getDepartmentId() {
return departmentId;
}
public Integer getEnrollmentYear() {
return enrollmentYear;
}
public Integer getCourse() {
return course;
}
public Integer getSemester() {
return semester;
}
public Long getSpecialityCode() {
return specialityCode;
}
} }

View File

@@ -1,11 +1,23 @@
package com.magistr.app.dto; package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LessonResponse { public class LessonResponse {
private Long id; private Long id;
private Long teacherId; private Long teacherId;
private String teacherName;
private Long groupId; private Long groupId;
private Long disciplineId; 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 day;
private String week; private String week;
private String time; private String time;
@@ -13,17 +25,25 @@ public class LessonResponse {
public LessonResponse() { public LessonResponse() {
} }
public LessonResponse(Long disciplineId, String day, String week, String time) { public LessonResponse(Long id, Long teacherId, Long groupId, Long subjectId, String day, String week, String time) {
this.disciplineId = disciplineId; this.id = id;
this.teacherId = teacherId;
this.groupId = groupId;
this.subjectId = subjectId;
this.day = day; this.day = day;
this.week = week; this.week = week;
this.time = time; this.time = time;
} }
public LessonResponse(Long id, Long teacherId, Long disciplineId, String day, String week, String 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.id = id;
this.teacherId = teacherId; this.teacherName = teacherName;
this.disciplineId = disciplineId; this.groupName = groupName;
this.classroomName = classroomName;
this.educationFormName = educationFormName;
this.subjectName = subjectName;
this.typeLesson = typeLesson;
this.lessonFormat = lessonFormat;
this.day = day; this.day = day;
this.week = week; this.week = week;
this.time = time; this.time = time;
@@ -45,6 +65,14 @@ public class LessonResponse {
this.teacherId = teacherId; this.teacherId = teacherId;
} }
public String getTeacherName() {
return teacherName;
}
public void setTeacherName(String teacherName) {
this.teacherName = teacherName;
}
public Long getGroupId() { public Long getGroupId() {
return groupId; return groupId;
} }
@@ -53,12 +81,68 @@ public class LessonResponse {
this.groupId = groupId; this.groupId = groupId;
} }
public Long getDisciplineId() { public String getGroupName() {
return disciplineId; return groupName;
} }
public void setDisciplineId(Long disciplineId) { public void setGroupName(String groupName) {
this.disciplineId = disciplineId; 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() { public String getDay() {

View File

View File

@@ -7,16 +7,18 @@ public class LoginResponse {
private String token; private String token;
private String role; private String role;
private String redirect; private String redirect;
private Long departmentId;
public LoginResponse() { 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 = success; this.success = success;
this.message = message; this.message = message;
this.token = token; this.token = token;
this.role = role; this.role = role;
this.redirect = redirect; this.redirect = redirect;
this.departmentId = departmentId;
} }
public boolean isSuccess() { public boolean isSuccess() {
@@ -58,4 +60,12 @@ public class LoginResponse {
public void setRedirect(String redirect) { public void setRedirect(String redirect) {
this.redirect = redirect; this.redirect = redirect;
} }
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
} }

View File

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

View File

@@ -0,0 +1,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,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

View File

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

View File

View File

@@ -0,0 +1,50 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false)
private String departmentName;
@Column(name = "code", nullable = false)
private Long departmentCode;
public Department() {}
public Department(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

View File

45
backend/src/main/java/com/magistr/app/model/Lesson.java Normal file → Executable file
View File

@@ -16,8 +16,17 @@ public class Lesson {
@Column(name = "group_id", nullable = false) @Column(name = "group_id", nullable = false)
private Long groupId; private Long groupId;
@Column(name = "discipline_id", nullable = false) @Column(name = "subject_id", nullable = false)
private Long disciplineId; private Long subjectId;
@Column(name = "lesson_format", nullable = false, length = 255)
private String lessonFormat;
@Column(name = "type_lesson", nullable = false, length = 255)
private String typeLesson;
@Column(name = "classroom_id", nullable = false)
private Long classroomId;
@Column(name = "day", nullable = false, length = 255) @Column(name = "day", nullable = false, length = 255)
private String day; private String day;
@@ -55,12 +64,36 @@ public class Lesson {
this.groupId = groupId; this.groupId = groupId;
} }
public Long getDisciplineId() { public Long getSubjectId() {
return disciplineId; return subjectId;
} }
public void setDisciplineId(Long disciplineId) { public void setSubjectId(Long subjectId) {
this.disciplineId = disciplineId; 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() { public String getDay() {

View File

@@ -0,0 +1,31 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="lesson_types")
public class LessonType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="name", nullable = false)
private String lessonType;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLessonType() {
return lessonType;
}
public void setLessonType(String lessonType) {
this.lessonType = lessonType;
}
}

0
backend/src/main/java/com/magistr/app/model/Role.java Normal file → Executable file
View File

View File

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

View File

@@ -0,0 +1,6 @@
package com.magistr.app.model;
public enum SemesterType {
spring,
autumn
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="specialties")
public class Speciality {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="name", nullable = false)
private String specialityName;
@Column(name="specialty_code",nullable = false)
private String specialityCode;
public Speciality() {}
public Speciality(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

@@ -1,5 +1,6 @@
package com.magistr.app.model; package com.magistr.app.model;
import com.magistr.app.utils.CourseCalculator;
import jakarta.persistence.*; import jakarta.persistence.*;
@Entity @Entity
@@ -13,10 +14,22 @@ public class StudentGroup {
@Column(unique = true, nullable = false, length = 100) @Column(unique = true, nullable = false, length = 100)
private String name; private String name;
@Column(name = "group_size", nullable = false)
private Long groupSize;
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "education_form_id", nullable = false) @JoinColumn(name = "education_form_id", nullable = false)
private EducationForm educationForm; private EducationForm educationForm;
@Column(name = "department_id", nullable = false)
private Long departmentId;
@Column(name = "enrollment_year", nullable = false)
private Integer enrollmentYear;
@Column(name="specialty_code", nullable = false)
private Long specialityCode;
public StudentGroup() { public StudentGroup() {
} }
@@ -36,6 +49,14 @@ public class StudentGroup {
this.name = name; this.name = name;
} }
public Long getGroupSize() {
return groupSize;
}
public void setGroupSize(Long groupSize) {
this.groupSize = groupSize;
}
public EducationForm getEducationForm() { public EducationForm getEducationForm() {
return educationForm; return educationForm;
} }
@@ -43,4 +64,46 @@ public class StudentGroup {
public void setEducationForm(EducationForm educationForm) { public void setEducationForm(EducationForm educationForm) {
this.educationForm = educationForm; this.educationForm = educationForm;
} }
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Integer getEnrollmentYear() {
return enrollmentYear;
}
public void setEnrollmentYear(Integer enrollmentYear) {
this.enrollmentYear = enrollmentYear;
}
/**
* Вычисляемый курс на основе года начала обучения.
*/
@Transient
public Integer getCourse() {
if (enrollmentYear == null) return null;
return CourseCalculator.calculateCourse(enrollmentYear);
}
/**
* Вычисляемый семестр на основе года начала обучения.
*/
@Transient
public Integer getSemester() {
if (enrollmentYear == null) return null;
return CourseCalculator.calculateSemester(enrollmentYear);
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
}
} }

View File

@@ -13,12 +13,20 @@ public class Subject {
@Column(unique = true, nullable = false, length = 200) @Column(unique = true, nullable = false, length = 200)
private String name; private String name;
@Column(name = "code")
private String code;
@Column(name = "department_id", nullable = false)
private Long departmentId;
public Subject() { public Subject() {
} }
public Subject(Long id, String name) { public Subject(Long id, String name, String code, Long departmentId) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.code = code;
this.departmentId = departmentId;
} }
public Long getId() { public Long getId() {
@@ -36,4 +44,20 @@ public class Subject {
public void setName(String name) { public void setName(String name) {
this.name = 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

View File

33
backend/src/main/java/com/magistr/app/model/User.java Normal file → Executable file
View File

@@ -20,6 +20,15 @@ public class User {
@Column(nullable = false, length = 20) @Column(nullable = false, length = 20)
private Role role = Role.STUDENT; private Role role = Role.STUDENT;
@Column(name = "full_name", nullable = false)
private String fullName;
@Column(name="job_title", nullable = false)
private String jobTitle;
@Column(name="department_id", nullable = false)
private Long departmentId;
public User() { public User() {
} }
@@ -54,4 +63,28 @@ public class User {
public void setRole(Role role) { public void setRole(Role role) {
this.role = 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,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.Department;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface DepartmentRepository extends JpaRepository<Department, Long> {
Optional<Department> findByDepartmentName(String departmentName);
Optional<Department> findByDepartmentCode(Long departmentCode);
}

View File

@@ -11,4 +11,6 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
Optional<StudentGroup> findByName(String name); Optional<StudentGroup> findByName(String name);
List<StudentGroup> findByEducationFormId(Long educationFormId); List<StudentGroup> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId);
} }

View File

@@ -3,9 +3,12 @@ package com.magistr.app.repository;
import com.magistr.app.model.Lesson; import com.magistr.app.model.Lesson;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface LessonRepository extends JpaRepository<Lesson, Long> { public interface LessonRepository extends JpaRepository<Lesson, Long> {
Optional<Lesson> findByDisciplineId(Long disciplineId); Optional<Lesson> findBySubjectId(Long subjectId);
List<Lesson> findByTeacherId(Long teacherId);
} }

View File

@@ -0,0 +1,7 @@
package com.magistr.app.repository;
import com.magistr.app.model.LessonType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LessonTypesRepository extends JpaRepository<LessonType, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleData;
import com.magistr.app.model.SemesterType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ScheduleDataRepository extends JpaRepository<ScheduleData, Long> {
List<ScheduleData> findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period);
boolean existsByDepartmentIdAndSemesterAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
Long departmentId,
Long semester,
Long groupId,
Long subjectsId,
Long lessonTypeId,
Long numberOfHours,
Boolean division,
Long teacherId,
SemesterType semesterType,
String period
);
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.Speciality;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
Optional<Speciality> findBySpecialityName(String specialityName);
Optional<Speciality> findBySpecialityCode(String specialityCode);
}

View File

@@ -3,8 +3,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> { public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name); Optional<Subject> findByName(String name);
List<Subject> findByDepartmentId(Long departmentId);
} }

View File

@@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
List<User> findByRole(Role role); List<User> findByRole(Role role);
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
} }

View File

@@ -0,0 +1,53 @@
package com.magistr.app.utils;
import java.time.LocalDate;
/**
* Утилитный класс для вычисления курса и семестра группы
* на основе года начала обучения.
*/
public final class CourseCalculator {
private CourseCalculator() {
}
/**
* Вычисляет текущий курс группы.
* До сентября студент ещё на старом курсе, с сентября — на следующем.
*
* @param enrollmentYear год начала обучения (напр. 2023)
* @return номер курса (1, 2, 3, ...)
*/
public static int calculateCourse(int enrollmentYear) {
LocalDate now = LocalDate.now();
int currentYear = now.getYear();
int currentMonth = now.getMonthValue();
// С сентября начинается новый учебный год
if (currentMonth >= 9) {
return currentYear - enrollmentYear + 1;
} else {
return currentYear - enrollmentYear;
}
}
/**
* Вычисляет текущий семестр группы.
* Сентябрь–январь → нечётный (осенний) семестр, февраль–август → чётный (весенний).
*
* @param enrollmentYear год начала обучения (напр. 2023)
* @return номер семестра (1, 2, 3, ...)
*/
public static int calculateSemester(int enrollmentYear) {
int course = calculateCourse(enrollmentYear);
int currentMonth = LocalDate.now().getMonthValue();
// Сентябрь–январь: осенний (нечётный) семестр
// Февраль–август: весенний (чётный) семестр
if (currentMonth >= 9 || currentMonth <= 1) {
return (course - 1) * 2 + 1;
} else {
return (course - 1) * 2 + 2;
}
}
}

View File

@@ -0,0 +1,30 @@
package com.magistr.app.utils;
import java.util.Set;
public class DayAndWeekValidator {
private static final Set<String> VALID_DAYS = Set.of(
"Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"
);
private static final Set<String> VALID_WEEKS = Set.of(
"Верхняя", "Нижняя", "Обе"
);
public static boolean isValidDay(String day) {
return day != null && VALID_DAYS.contains(day);
}
public static boolean isValidWeek(String week) {
return week != null && VALID_WEEKS.contains(week);
}
public static String getValidDaysMessage() {
return "Допустимые дни: " + String.join(", ", VALID_DAYS);
}
public static String getValidWeekMessage() {
return "Допустимы для выбора: " + String.join(", ", VALID_WEEKS);
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.utils;
import com.magistr.app.model.SemesterType;
import java.util.Arrays;
public class SemesterTypeValidator {
public static boolean isValidTypeSemester(String semesterType) {
if (semesterType == null) {
return false;
}
try {
SemesterType.valueOf(semesterType);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
public static String getValidTypes() {
return String.join(", ", Arrays.stream(SemesterType.values())
.map(Enum::name)
.toArray(String[]::new));
}
}

View File

@@ -0,0 +1,30 @@
package com.magistr.app.utils;
import java.util.Set;
public class TypeAndFormatLessonValidator {
private static final Set<String> VALID_TYPES = Set.of(
"Лекция", "Лабораторная работа", "Практическая работа"
);
private static final Set<String> VALID_FORMATS = Set.of(
"Онлайн", "Очно"
);
public static boolean isValidType(String type) {
return type != null && VALID_TYPES.contains(type);
}
public static boolean isValidFormat(String format) {
return format != null && VALID_FORMATS.contains(format);
}
public static String getValidTypesMessage() {
return "Допустимые типы: " + String.join(", ", VALID_TYPES);
}
public static String getValidFormatsMessage() {
return "Допустимые форматы: " + String.join(", ", VALID_FORMATS);
}
}

14
backend/src/main/resources/application.properties Normal file → Executable file
View File

@@ -1,12 +1,18 @@
server.port=8080 server.port=8080
# PostgreSQL # PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
spring.datasource.url=jdbc:postgresql://db:5432/app_db spring.datasource.url=jdbc:postgresql://db:5432/app_db
spring.datasource.username=${POSTGRES_USER} spring.datasource.username=${POSTGRES_USER:myuser}
spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# JPA # JPA
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=false spring.jpa.show-sql=false
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
# Мультитенантность
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
#logging.level.root=DEBUG

View File

@@ -0,0 +1,386 @@
-- ==========================================
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ===============================
-- Справочники высшего уровня
-- ===============================
CREATE TABLE IF NOT EXISTS departments (
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
code BIGINT UNIQUE NOT NULL
);
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
specialty_code VARCHAR(255) NOT NULL
);
INSERT INTO specialties (name, specialty_code) VALUES
('Информационная безопасность', '10.03.01'),
('Информатика и вычислительная техника', '09.03.01'),
('Программная инженерия', '09.03.04');
-- ==========================================
-- Пользователи и роли
-- ==========================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
department_id BIGINT NOT NULL REFERENCES departments(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2)
ON CONFLICT (username) DO NOTHING;
-- ==========================================
-- Образовательные формы
-- ==========================================
CREATE TABLE IF NOT EXISTS education_forms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO education_forms (name) VALUES
('Бакалавриат'),
('Магистратура'),
('Специалитет')
ON CONFLICT (name) DO NOTHING;
-- ==========================================
-- Учебные группы
-- ==========================================
CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
group_size BIGINT NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
department_id BIGINT NOT NULL REFERENCES departments(id),
course INT CHECK (course BETWEEN 1 AND 6),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, department_id, course)
VALUES ('ИВТ-21-1', 25, 1, 1, 3),
('ИБ-41м', 15, 2, 1, 2)
ON CONFLICT (name) DO NOTHING;
-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ==========================================
CREATE TABLE IF NOT EXISTS subgroups (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
student_capacity INT,
UNIQUE(group_id, name)
);
-- ==========================================
-- Справочники
-- ==========================================
-- Дисциплины
CREATE TABLE IF NOT EXISTS subjects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) UNIQUE NOT NULL,
code VARCHAR(20),
department_id BIGINT NOT NULL REFERENCES departments(id),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT (name) DO NOTHING;
-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
color_code VARCHAR(7) DEFAULT '#3788d8',
duration_minutes INT DEFAULT 90
);
INSERT INTO lesson_types (name, color_code) VALUES
('Лекция', '#FF6B6B'),
('Практика', '#4ECDC4'),
('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
inventory_number VARCHAR(50)
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
building VARCHAR(50),
floor INT,
is_available BOOLEAN DEFAULT TRUE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO classrooms (name, capacity, building, floor) VALUES
('101 Ленинская', 120, 'Главный корпус', 1),
('202 IT Lab', 20, 'Корпус IT', 2),
('303 Обычная', 30, 'Главный корпус', 3)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
quantity INT DEFAULT 1 CHECK (quantity > 0),
notes TEXT,
PRIMARY KEY (classroom_id, equipment_id)
);
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
WHEN e.name = 'ПК' THEN 1
ELSE 1
END
FROM classrooms c, equipments e
WHERE
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
-- ==========================================
-- Связи для преподавателей
-- ==========================================
CREATE TABLE IF NOT EXISTS teacher_subjects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
qualification_level VARCHAR(50),
experience_years INT,
PRIMARY KEY(user_id, subject_id)
);
CREATE TABLE IF NOT EXISTS teacher_lesson_types (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, subject_id, lesson_type_id)
);
-- ==========================================
-- Основная таблица Расписания (Lessons)
-- ==========================================
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
group_id BIGINT NOT NULL REFERENCES student_groups(id),
subject_id BIGINT NOT NULL REFERENCES subjects(id),
lesson_format VARCHAR(255) NOT NULL,
type_lesson VARCHAR(255) NOT NULL,
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
day VARCHAR(255) NOT NULL,
week VARCHAR(255) NOT NULL,
time VARCHAR(255) NOT NULL
);
INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES
(2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'),
(1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'),
(2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'),
(1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'),
(2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'),
(1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30');
-- ===============================
-- Создание таблицы данных расписания (schedule_data)
-- ===============================
CREATE TABLE IF NOT EXISTS schedule_data (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
semester INT NOT NULL,
group_id BIGINT NOT NULL REFERENCES student_groups(id),
subjects_id BIGINT NOT NULL REFERENCES subjects(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
number_of_hours INT NOT NULL,
is_division BOOLEAN NOT NULL DEFAULT FALSE,
teacher_id BIGINT NOT NULL REFERENCES users(id),
semester_type VARCHAR(255) NOT NULL,
period VARCHAR(255) NOT NULL
);
INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'),
(2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'),
(3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024');
-- ==========================================
-- Функция обновления timestamp
-- ==========================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ==========================================
-- Комментарии к таблицам и полям (для документации)
-- ==========================================
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
COMMENT ON TABLE lessons IS 'Основное расписание занятий';
COMMENT ON TABLE departments IS 'Кафедры';
COMMENT ON TABLE specialties IS 'Специальности';
COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания';
COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры';
COMMENT ON COLUMN schedule_data.semester IS 'Номер семестра';
COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы';
COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета';
COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия';
COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов';
COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным';
COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя';
COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)';
COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)';
COMMENT ON TABLE education_forms IS 'Формы обучения';
COMMENT ON TABLE subgroups IS 'Подгруппы';
COMMENT ON TABLE lesson_types IS 'Типы занятий';
COMMENT ON TABLE equipments IS 'Оборудование';
COMMENT ON TABLE classrooms IS 'Аудитории';
COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям';
COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам';
COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя';
COMMENT ON COLUMN users.id IS 'ID пользователя';
COMMENT ON COLUMN users.username IS 'Логин пользователя';
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
COMMENT ON COLUMN users.role IS 'Роль пользователя';
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
COMMENT ON COLUMN users.department_id IS 'ID кафедры';
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
COMMENT ON COLUMN education_forms.description IS 'Описание';
COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN student_groups.id IS 'ID учебной группы';
COMMENT ON COLUMN student_groups.name IS 'Название группы';
COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов';
COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа';
COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры';
COMMENT ON COLUMN student_groups.course IS 'Курс';
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';
COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа';
COMMENT ON COLUMN subgroups.name IS 'Название подгруппы';
COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе';
COMMENT ON COLUMN subjects.id IS 'ID предмета';
COMMENT ON COLUMN subjects.name IS 'Название предмета';
COMMENT ON COLUMN subjects.code IS 'Код предмета';
COMMENT ON COLUMN subjects.department_id IS 'ID кафедры';
COMMENT ON COLUMN subjects.description IS 'Описание предмета';
COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания';
COMMENT ON COLUMN lesson_types.id IS 'ID урока';
COMMENT ON COLUMN lesson_types.name IS 'Название типа урока';
COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока';
COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах';
COMMENT ON COLUMN equipments.id IS 'ID оборудования';
COMMENT ON COLUMN equipments.name IS 'Название оборудования';
COMMENT ON COLUMN equipments.description IS 'Описание оборудования';
COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования';
COMMENT ON COLUMN classrooms.id IS 'ID аудитории';
COMMENT ON COLUMN classrooms.name IS 'Название аудитории';
COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории';
COMMENT ON COLUMN classrooms.building IS 'Корпус';
COMMENT ON COLUMN classrooms.floor IS 'Этаж';
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';
COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2
COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи';
COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя';
COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания';
COMMENT ON COLUMN lessons.id IS 'ID урока';
COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок';
COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок';
COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается';
COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока';
COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока';
COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок';
COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок';
COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок';
COMMENT ON COLUMN lessons.time IS 'Время урока';
COMMENT ON COLUMN departments.id IS 'ID кафедры';
COMMENT ON COLUMN departments.name IS 'Название кафедры';
COMMENT ON COLUMN departments.code IS 'Код кафедры';
COMMENT ON COLUMN specialties.id IS 'ID специальности';
COMMENT ON COLUMN specialties.name IS 'Название специальности';
COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности';
COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';

View File

@@ -0,0 +1,50 @@
-- ==========================================
-- Редактирование учебных групп
-- ==========================================
ALTER TABLE student_groups
ADD COLUMN IF NOT EXISTS specialty_code INT REFERENCES specialties(id);
UPDATE student_groups
SET specialty_code = 1
WHERE specialty_code IS NULL;
ALTER TABLE student_groups
ALTER COLUMN specialty_code SET NOT NULL;
-- ==========================================
-- Редактирование данных для расписания
-- ==========================================
INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
VALUES (1, 1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(3, 5, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(1, 1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'),
(1, 2, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'),
(1, 3, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'),
(1, 4, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025');
-- ==========================================
-- Год начала обучения вместо статического курса
-- ==========================================
ALTER TABLE student_groups
ADD COLUMN IF NOT EXISTS enrollment_year INT;
-- Обратный расчёт: enrollment_year = текущий_год - course + 1
-- (для месяцев до сентября курс ещё не увеличился)
UPDATE student_groups
SET enrollment_year = EXTRACT(YEAR FROM NOW())::INT - course
+ CASE WHEN EXTRACT(MONTH FROM NOW()) >= 9 THEN 1 ELSE 0 END;
ALTER TABLE student_groups
ALTER COLUMN enrollment_year SET NOT NULL;
ALTER TABLE student_groups
DROP COLUMN IF EXISTS course;
COMMENT ON COLUMN student_groups.enrollment_year IS 'Год начала обучения группы';

9
backend/tenants.json Executable file
View File

@@ -0,0 +1,9 @@
[
{
"name": "Default (dev)",
"domain": "default",
"url": "jdbc:postgresql://db:5432/app_db",
"username": "myuser",
"password": "supersecretpassword"
}
]

24
compose.yaml Normal file → Executable file
View File

@@ -8,11 +8,12 @@ services:
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- proxy
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks:
- proxy
frontend: frontend:
container_name: frontend container_name: frontend
restart: always restart: always
@@ -23,6 +24,7 @@ services:
- proxy - proxy
depends_on: depends_on:
- backend - backend
db: db:
image: postgres:alpine3.23 image: postgres:alpine3.23
container_name: db container_name: db
@@ -30,21 +32,17 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: myuser
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: supersecretpassword
POSTGRES_DB: app_db POSTGRES_DB: app_db
volumes:
- ./db/data:/var/lib/postgresql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- proxy
healthcheck: healthcheck:
test: test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
- CMD-SHELL interval: 5s
- pg_isready -U ${POSTGRES_USER} -d app_db
interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- proxy
networks: networks:
proxy: proxy:
external: true external: true

View File

@@ -1,141 +0,0 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT'
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS education_forms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет')
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id)
);
-- ==========================================
-- Справочники
-- ==========================================
-- Дисциплины
CREATE TABLE IF NOT EXISTS subjects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) UNIQUE NOT NULL
);
INSERT INTO subjects (name) VALUES
('Высшая математика'),
('Философия'),
('Информатика'),
('Базы данных'),
('Английский язык')
ON CONFLICT (name) DO NOTHING;
-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO lesson_types (name) VALUES
('Лекция'),
('Практика'),
('Лабораторная работа')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
capacity INT NOT NULL,
is_available BOOLEAN DEFAULT TRUE
);
INSERT INTO classrooms (name, capacity) VALUES
('101 Ленинская', 120),
('202 IT Lab', 20),
('303 Обычная', 30)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
PRIMARY KEY (classroom_id, equipment_id)
);
-- Заполнение привязок оборудования (на основе ID базовых данных)
-- '101 Ленинская' -> Проектор (1), Интерактивная доска (4)
INSERT INTO classroom_equipments (classroom_id, equipment_id) VALUES
(1, 1), (1, 4),
-- '202 IT Lab' -> ПК (2), Проектор (1), Лаборатория (3)
(2, 2), (2, 1), (2, 3)
-- '303 Обычная' -> ничего
ON CONFLICT DO NOTHING;
-- ==========================================
-- Связи для преподавателей и студентов
-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
CREATE TABLE IF NOT EXISTS subgroups (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
UNIQUE(group_id, name)
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, education_form_id)
VALUES ('ИВТ-21-1', 1)
ON CONFLICT (name) DO NOTHING;
-- Привязка преподавателей к дисциплинам
CREATE TABLE IF NOT EXISTS teacher_subjects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
PRIMARY KEY(user_id, subject_id)
);
-- ==========================================
-- Основная таблица Расписания (Lessons)
-- ==========================================
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
subject_id BIGINT NOT NULL REFERENCES subjects(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
group_id BIGINT NOT NULL REFERENCES student_groups(id), -- первичная группа
subgroup_id BIGINT REFERENCES subgroups(id), -- необязательно (если делим группу)
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), -- 1=Понедельник, 7=Воскресенье
is_even_week BOOLEAN NOT NULL, -- Четная/нечетная неделя
start_time TIME NOT NULL, -- Напр. '08:00:00'
end_time TIME NOT NULL -- Напр. '09:30:00'
);

482
docs/API.md Normal file
View File

@@ -0,0 +1,482 @@
# 🔌 REST API
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
---
## Аутентификация
### `POST /api/auth/login`
Вход в систему.
**Тело запроса:**
```json
{
"username": "admin",
"password": "admin"
}
```
**Успешный ответ (200):**
```json
{
"success": true,
"message": "OK",
"token": "550e8400-e29b-41d4-a716-446655440000",
"role": "ADMIN",
"redirect": "/admin/"
}
```
**Ошибка (401):**
```json
{
"success": false,
"message": "Неверное имя пользователя или пароль",
"token": null,
"role": null,
"redirect": null
}
```
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
---
## Пользователи
### `GET /api/users`
Список всех пользователей.
**Ответ:**
```json
[
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
]
```
### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
### `POST /api/users`
Создание пользователя.
**Тело запроса:**
```json
{
"username": "teacher1",
"password": "password",
"role": "TEACHER",
"fullName": "Test Teacher",
"jobTitle": "Proffessor",
"departmentId": 1
}
```
**Валидация:**
- `username` — обязателен и уникален
- `password` — минимум 4 символа
- `role``ADMIN`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
### `DELETE /api/users/{id}`
Удаление пользователя.
---
## Расписание (Lessons)
### `GET /api/users/lessons`
Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория).
**Ответ:**
```json
[
{
"id": 1,
"teacherName": "Тестовый преподаватель",
"groupName": "ИВТ-21-1",
"classroomName": "101 Ленинская",
"educationFormName": "Бакалавриат",
"subjectName": "Высшая математика",
"typeLesson": "Лекция",
"lessonFormat": "Очно",
"day": "Понедельник",
"week": "Верхняя",
"time": "11:40 - 13:10"
}
]
```
### `GET /api/users/lessons/{teacherId}`
Занятия конкретного преподавателя.
### `POST /api/users/lessons/create`
Создание занятия.
**Тело запроса:**
```json
{
"teacherId": 2,
"groupId": 1,
"subjectId": 1,
"lessonFormat": "Очно",
"typeLesson": "Лекция",
"classroomId": 1,
"day": "Понедельник",
"week": "Верхняя",
"time": "11:40 - 13:10"
}
```
**Валидация:**
| Поле | Правило |
|------|---------|
| `teacherId` | Обязателен, ≠ 0 |
| `groupId` | Обязателен, ≠ 0 |
| `subjectId` | Обязателен, ≠ 0 |
| `lessonFormat` | `Очно` или `Онлайн` |
| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` |
| `classroomId` | Обязателен, ≠ 0 |
| `day` | Пн–Сб (на русском) |
| `week` | `Верхняя`, `Нижняя`, `Обе` |
| `time` | Обязателен |
### `PUT /api/users/lessons/update/{lessonId}`
Обновление занятия. Поддерживает partial update — передаются только изменённые поля.
**Тело ответа:**
```json
{
"id": 5,
"teacherId": 1,
"groupId": 1,
"subjectId": 2,
"LessonFormat": "Онлайн",
"typeLesson": "Практическая работа",
"classroomId": 3,
"day": "Понедельник",
"week": "Верхняя",
"time": "9:40 - 11:10",
"updatedFields": {
"teacherId": 1,
"subjectId": 2,
"lessonFormat": "Онлайн",
"classroomId": 3,
"day": "Понедельник",
"time": "9:40 - 11:10"
},
"message": "Занятие успешно обновлено"
}
```
### `DELETE /api/users/lessons/delete/{lessonId}`
Удаление занятия.
### `GET /api/users/lessons/ping`
Проверка доступности контроллера. Возвращает строку `pong`.
---
## Группы
### `GET /api/groups`
Список всех групп.
**Ответ:**
```json
[
{
"id": 1,
"name": "ИВТ-21-1",
"groupSize": 25,
"educationFormId": 1,
"educationFormName": "Бакалавриат",
"departmentId": 1,
"course": 3,
"specialityCode": 1
}
]
```
### `GET /api/groups/{departmentId}`
Список всех групп привязанных к конкретной кафедре.
### `POST /api/groups`
Создание группы.
```json
{
"name": "ИВТ-11",
"groupSize": 12,
"educationFormId": 1,
"departmentId": 1,
"course": 2,
"specialityCode": 1
}
```
### `DELETE /api/groups/{id}`
Удаление группы.
---
## Аудитории
### `GET /api/classrooms`
Список аудиторий с привязанным оборудованием.
**Ответ:**
```json
[
{
"id": 1,
"name": "101 Ленинская",
"capacity": 120,
"isAvailable": true,
"equipments": [
{ "id": 1, "name": "Проектор" },
{ "id": 4, "name": "Интерактивная доска" }
]
}
]
```
### `POST /api/classrooms`
Создание аудитории.
```json
{
"name": "404 Лаборатория",
"capacity": 30,
"isAvailable": true,
"equipmentIds": [1, 2, 3]
}
```
### `PUT /api/classrooms/{id}`
Обновление аудитории (partial update).
### `DELETE /api/classrooms/{id}`
Удаление аудитории.
---
## Дисциплины
### `GET /api/subjects`
Список всех дисциплин.
```json
{
"name": "Физика",
"code": null,
"departmentId": 1
}
```
### `GET /api/subjects/{departmentId}`
Список всех дисциплин привязанных к кафедре.
### `POST /api/subjects`
```json
{
"name": "Физика",
"code": null,
"departmentId": 1
}
```
### `DELETE /api/subjects/{id}`
Удаление дисциплины.
---
## Оборудование
### `GET /api/equipments`
Список всего оборудования.
### `POST /api/equipments`
```json
{ "name": "3D-принтер" }
```
### `DELETE /api/equipments/{id}`
Удаление оборудования.
---
## Формы обучения
### `GET /api/education-forms`
Список форм обучения.
**Ответ:**
```json
[
{ "id": 1, "name": "Бакалавриат" },
{ "id": 2, "name": "Магистратура" }
]
```
### `POST /api/education-forms`
```json
{ "name": "Аспирантура" }
```
### `DELETE /api/education-forms/{id}`
Удаление формы обучения. **Невозможно**, если к ней привязаны группы.
---
## Привязка «Преподаватель ↔ Дисциплина»
### `GET /api/teacher-subjects`
Список всех привязок.
**Ответ:**
```json
[
{
"userId": 2,
"userName": "Тестовый преподаватель",
"subjectId": 1,
"subjectName": "Высшая математика"
}
]
```
### `POST /api/teacher-subjects`
```json
{
"userId": 2,
"subjectId": 3
}
```
### `DELETE /api/teacher-subjects`
```json
{
"userId": 2,
"subjectId": 3
}
```
---
## Управление тенантами (Базы данных)
### `GET /api/database/status`
Статус текущего подключения (определяется по домену запроса).
**Ответ:**
```json
{
"tenant": "default",
"connected": true,
"configured": true,
"name": "Default",
"url": "jdbc:postgresql://db:5432/app_db"
}
```
### `GET /api/database/tenants`
Список всех тенантов.
### `POST /api/database/tenants`
Добавление нового тенанта.
```json
{
"name": "СВФУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
```
**Логика:**
1. Создаёт HikariCP пул для нового тенанта
2. Запускает Flyway миграции на его БД
3. Обновляет Kubernetes ConfigMap
### `DELETE /api/database/tenants/{domain}`
Удаление тенанта.
### `POST /api/database/test`
Тест подключения к произвольной БД (без регистрации тенанта).
```json
{
"url": "jdbc:postgresql://host:5432/testdb",
"username": "user",
"password": "pass"
}
```
**Ответ:**
```json
{
"success": true,
"message": "Подключение успешно!"
}
```
---
## Коды ответов
| Код | Описание |
|-----|----------|
| `200` | Успех |
| `400` | Ошибка валидации (с `message` в теле) |
| `401` | Неверные учётные данные |
| `404` | Ресурс / тенант не найден |
| `500` | Внутренняя ошибка сервера |

142
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,142 @@
# 🏗 Архитектура системы
## Общая схема
```mermaid
graph TD
Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"]
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd:alpine)"]
Caddy -->|/api/*| Backend["Backend<br/>(Spring Boot 3.2.5)"]
Backend --> TenantRouter{"TenantRoutingDataSource"}
TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL<br/>swsu_db"]
TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL<br/>mgu_db"]
TenantRouter -->|...| DBn["PostgreSQL<br/>tenant_n_db"]
Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"]
OTel --> SigNoz["SigNoz"]
```
## Компоненты
### Frontend (Apache httpd:alpine)
- **Тип:** Статические файлы (HTML/CSS/JS)
- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server
- **Порт:** 80
- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/`
- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`)
### Backend (Spring Boot 3.2.5)
- **Тип:** REST API сервер
- **Язык:** Java 17
- **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта)
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены
### PostgreSQL
- **Версия:** `postgres:alpine3.23`
- **Локально:** Одна БД `app_db` (тенант `default`)
- **Продакшн:** Множество БД, по одной на каждый университет (тенант)
### Caddy (реверс-прокси)
- **Расположение:** `../caddy-proxy/`
- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend
- **Домен:** `*.zuev.company`
---
## Мультитенантная архитектура
Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL.
### Принцип работы
```mermaid
sequenceDiagram
participant Browser as Браузер
participant Interceptor as TenantInterceptor
participant Context as TenantContext
participant Router as TenantRoutingDataSource
participant DB as PostgreSQL
Browser->>Interceptor: GET /api/users<br/>Host: swsu.zuev.company
Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu"
Interceptor->>Context: setCurrentTenant("swsu")
Note over Interceptor: Проверка: hasTenant("swsu")?
Interceptor-->>Browser: 404 если тенант не найден
Note over Context,Router: Обработка запроса контроллером
Router->>Router: determineCurrentLookupKey() → "swsu"
Router->>DB: SQL запрос к swsu_db
DB-->>Browser: Ответ с данными
```
### Ключевые классы
| Класс | Назначение |
|-------|-----------|
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
Логика определения тенанта по заголовку `Host`:
| Host | Результат |
|------|----------|
| `swsu.zuev.company` | `swsu` |
| `mgu.zuev.company` | `mgu` |
| `localhost` | `default` |
| `localhost:8080` | `default` |
| `192.168.1.1` | `default` |
### Конфигурация тенантов
Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json`
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
Формат:
```json
[
{
"name": "ЮЗГУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
]
```
### Жизненный цикл тенанта
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
### Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта:
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
---
## Аутентификация
Система использует **простую модель аутентификации** без JWT или Spring Security фильтров:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
2. Backend проверяет пароль через `BCryptPasswordEncoder`
3. При успехе возвращается:
- UUID-токен (для заголовка `Authorization: Bearer`)
- Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`)
- Redirect URL (`/admin/`, `/teacher/`, `/student/`)
4. Токен хранится в `localStorage` на клиенте

149
docs/BUSINESS_LOGIC.md Normal file
View File

@@ -0,0 +1,149 @@
# 📋 Бизнес-логика
## Ролевая модель
Система поддерживает три роли пользователей:
| Роль | Enum | Возможности |
|------|------|------------|
| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
После авторизации пользователь перенаправляется на свой интерфейс:
- `ADMIN``/admin/`
- `TEACHER``/teacher/`
- `STUDENT``/student/`
---
## Управление ресурсами
### Кафедры (Departments)
Организационные единицы университета. К кафедре привязываются пользователи, группы и дисциплины.
- Имеют уникальный числовой `code`
- Предзаполнены: «Кафедра ИБ», «Кафедра ВТ», «Кафедра КТ»
### Специальности (Specialties)
Учебные направления с кодом по ФГОС.
- Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04)
### Формы обучения (Education Forms)
Уровни/формы обучения для привязки к группам.
- Предзаполнены: Бакалавриат, Магистратура, Специалитет
- Нельзя удалить форму обучения, если к ней привязаны группы
### Учебные группы (Student Groups)
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (16)
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
### Аудитории (Classrooms)
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
- **Статус:** Флаг `is_available` для блокирования назначения пар
### Оборудование (Equipments)
Каталог оборудования для привязки к аудиториям.
- Предзаполнены: Проектор, ПК, Лаборатория, Интерактивная доска, Документ-камера, Аудиосистема
- Уникальность по названию
### Дисциплины (Subjects)
- **Поля:** Название (уникальное), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
---
## Логика расписания
### Сущность «Занятие» (Lesson)
Каждая запись в расписании содержит:
| Поле | Описание | Пример |
|------|----------|--------|
| `teacher_id` | Преподаватель | 2 |
| `group_id` | Учебная группа | 1 |
| `subject_id` | Дисциплина | 3 |
| `lesson_format` | Формат проведения | `Очно`, `Онлайн` |
| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` |
| `classroom_id` | Аудитория | 1 |
| `day` | День недели | `Понедельник` ... `Суббота` |
| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` |
| `time` | Временной слот | `8:00 - 9:30` |
### Временны́е слоты
Система использует 7 фиксированных слотов по 90 минут:
| № | Время |
|---|-------|
| 1 | 08:00 09:30 |
| 2 | 09:40 11:10 |
| 3 | 11:40 13:10 |
| 4 | 13:30 15:00 |
| 5 | 15:00 16:30 |
| 6 | 16:40 18:10 |
| 7 | 18:30 20:00 |
### Валидация при создании/обновлении
- **Дни:** только `Понедельник` `Суббота` (`DayAndWeekValidator`)
- **Недели:** только `Верхняя`, `Нижняя`, `Обе`
- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`)
- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа`
- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0
### Данные к составлению расписания (Schedule Data)
Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания:
| Поле | Описание |
|------|----------|
| `department_id` | Кафедра |
| `semester` | Номер семестра |
| `group_id` | Учебная группа |
| `subjects_id` | Дисциплина |
| `lesson_type_id` | Тип занятия |
| `number_of_hours` | Количество часов |
| `is_division` | Деление на подгруппы |
| `teacher_id` | Преподаватель |
| `semester_type` | Тип семестра (Весенний / Осенний) |
| `period` | Учебный год (напр. `2024/2025`) |
---
## Привязка преподаватель ↔ дисциплина
Связь Many-to-Many через таблицу `teacher_subjects`:
- Указывается, какие дисциплины может вести конкретный преподаватель
- Дополнительные поля: `qualification_level`, `experience_years`
Дополнительная связь через `teacher_lesson_types`:
- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине
---
## Бизнес-правила (планируемые)
> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.
### Проверка конфликтов
- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях
- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
### Управление инцидентами
- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
- Автоматическая подсветка конфликтующих пар (Red Zone)
- Resolution Wizard: предложение замены преподавателя или переноса занятия

367
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,367 @@
# 🗄 База данных
## Общая информация
- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей)
- **Мультитенантность:** Каждый тенант = отдельная БД
---
## ER-диаграмма
```mermaid
erDiagram
departments {
BIGSERIAL id PK
VARCHAR name
BIGINT code UK
}
specialties {
BIGSERIAL id PK
VARCHAR name
VARCHAR specialty_code
}
users {
BIGSERIAL id PK
VARCHAR username UK
VARCHAR password
VARCHAR role
VARCHAR full_name
VARCHAR job_title
BIGINT department_id FK
TIMESTAMP created_at
TIMESTAMP updated_at
}
education_forms {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
TIMESTAMP created_at
}
student_groups {
BIGSERIAL id PK
VARCHAR name UK
BIGINT group_size
BIGINT education_form_id FK
BIGINT department_id FK
INT enrollment_year
INT specialty_code FK
TIMESTAMP created_at
}
subgroups {
BIGSERIAL id PK
BIGINT group_id FK
VARCHAR name
INT student_capacity
}
subjects {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR code
BIGINT department_id FK
TEXT description
TIMESTAMP created_at
}
lesson_types {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR color_code
INT duration_minutes
}
equipments {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
VARCHAR inventory_number
}
classrooms {
BIGSERIAL id PK
VARCHAR name UK
INT capacity
VARCHAR building
INT floor
BOOLEAN is_available
TEXT description
TIMESTAMP created_at
}
classroom_equipments {
BIGINT classroom_id FK,PK
BIGINT equipment_id FK,PK
INT quantity
TEXT notes
}
teacher_subjects {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
VARCHAR qualification_level
INT experience_years
}
teacher_lesson_types {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
BIGINT lesson_type_id FK,PK
}
lessons {
BIGSERIAL id PK
BIGINT teacher_id FK
BIGINT group_id FK
BIGINT subject_id FK
VARCHAR lesson_format
VARCHAR type_lesson
BIGINT classroom_id FK
VARCHAR day
VARCHAR week
VARCHAR time
}
schedule_data {
BIGSERIAL id PK
BIGINT department_id FK
INT semester
BIGINT group_id FK
BIGINT subjects_id FK
BIGINT lesson_type_id FK
INT number_of_hours
BOOLEAN is_division
BIGINT teacher_id FK
VARCHAR semester_type
VARCHAR period
}
departments ||--o{ users : "department_id"
departments ||--o{ student_groups : "department_id"
departments ||--o{ subjects : "department_id"
departments ||--o{ schedule_data : "department_id"
education_forms ||--o{ student_groups : "education_form_id"
student_groups ||--o{ subgroups : "group_id"
student_groups ||--o{ lessons : "group_id"
student_groups ||--o{ schedule_data : "group_id"
users ||--o{ lessons : "teacher_id"
users ||--o{ teacher_subjects : "user_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ schedule_data : "teacher_id"
subjects ||--o{ lessons : "subject_id"
subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ schedule_data : "subjects_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_data : "lesson_type_id"
classrooms ||--o{ lessons : "classroom_id"
classrooms ||--o{ classroom_equipments : "classroom_id"
equipments ||--o{ classroom_equipments : "equipment_id"
```
---
## Описание таблиц
### Справочники высшего уровня
#### `departments` — Кафедры
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID кафедры |
| `name` | VARCHAR(255) | Название кафедры |
| `code` | BIGINT UNIQUE | Код кафедры |
#### `specialties` — Специальности
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID специальности |
| `name` | VARCHAR(255) | Название специальности |
| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) |
### Пользователи
#### `users` — Пользователи системы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID пользователя |
| `username` | VARCHAR(50) UNIQUE | Логин |
| `password` | VARCHAR(255) | bcrypt-хеш пароля |
| `role` | VARCHAR(20) | `ADMIN`, `TEACHER`, `STUDENT` |
| `full_name` | VARCHAR(255) | ФИО |
| `job_title` | VARCHAR(255) | Должность |
| `department_id` | BIGINT FK → departments | Кафедра |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
### Учебный процесс
#### `education_forms` — Формы обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) |
| `description` | TEXT | Описание |
#### `student_groups` — Учебные группы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) |
| `group_size` | BIGINT | Количество студентов |
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
| `department_id` | BIGINT FK → departments | Кафедра |
| `enrollment_year` | INT NOT NULL | Год начала обучения (напр. 2023) |
| `specialty_code` | INT FK → specialties | Код специальности |
> **Примечание:** Курс и семестр **вычисляются динамически** на основе `enrollment_year` и текущей даты (утилита `CourseCalculator.java`). В БД не хранятся.
#### `subgroups` — Подгруппы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов |
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(200) UNIQUE | Название |
| `code` | VARCHAR(20) | Код предмета |
| `department_id` | BIGINT FK → departments | Кафедра |
| `description` | TEXT | Описание |
### Аудиторный фонд
#### `classrooms` — Аудитории
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название (напр. `101 Ленинская`) |
| `capacity` | INT CHECK(> 0) | Вместимость |
| `building` | VARCHAR(50) | Корпус |
| `floor` | INT | Этаж |
| `is_available` | BOOLEAN | Доступна для назначения пар |
| `description` | TEXT | Описание |
#### `equipments` — Оборудование
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название |
| `description` | TEXT | Описание |
| `inventory_number` | VARCHAR(50) | Инвентарный номер |
#### `classroom_equipments` — Привязка оборудования к аудиториям
| Колонка | Тип | Описание |
|---------|-----|----------|
| `classroom_id` | BIGINT PK, FK → classrooms (CASCADE) | Аудитория |
| `equipment_id` | BIGINT PK, FK → equipments (CASCADE) | Оборудование |
| `quantity` | INT CHECK(> 0) | Количество единиц |
| `notes` | TEXT | Примечания |
### Расписание
#### `lessons` — Основное расписание занятий
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `group_id` | BIGINT FK → student_groups | Группа |
| `subject_id` | BIGINT FK → subjects | Дисциплина |
| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` |
| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` |
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `day` | VARCHAR(255) | День недели |
| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` |
| `time` | VARCHAR(255) | Временной слот |
#### `lesson_types` — Типы занятий (справочник)
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название типа |
| `color_code` | VARCHAR(7) | HEX-цвет для UI (напр. `#FF6B6B`) |
| `duration_minutes` | INT | Длительность (по умолчанию 90) |
### Связи «Преподаватель ↔ Дисциплина»
#### `teacher_subjects` — Квалификация преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `qualification_level` | VARCHAR(50) | Уровень квалификации |
| `experience_years` | INT | Стаж |
#### `teacher_lesson_types` — Типы занятий преподавателя
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
#### `schedule_data` — Данные к составлению расписания
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `department_id` | BIGINT FK → departments | Кафедра |
| `semester` | INT | Номер семестра |
| `group_id` | BIGINT FK → student_groups | Группа |
| `subjects_id` | BIGINT FK → subjects | Дисциплина |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `number_of_hours` | INT | Количество часов |
| `is_division` | BOOLEAN | Деление на подгруппы |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `semester_type` | VARCHAR(255) | Весенний / Осенний |
| `period` | VARCHAR(255) | Учебный год |
---
## Flyway миграции
### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии |
| `V2__editScheduleData.sql` | Добавление `specialty_code`, тестовые данные расписания, замена `course``enrollment_year` |
### Накатывание на существующих тенантов
Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend:
```bash
# Kubernetes
kubectl rollout restart deployment backend -n magistr
# Docker Compose (локально)
docker compose restart backend
```
### Полный сброс БД (локально)
```bash
docker compose down -v # Удаляет volumes (данные)
docker compose up -d # Пересоздаёт БД с нуля
```

275
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,275 @@
# 🛠 Руководство для разработчиков
## Локальный запуск
### Предварительные требования
- Docker и Docker Compose
- Git
- (Опционально) Java 17 + Maven 3.9+ для запуска backend вне Docker
### Первый запуск
```bash
# Создать Docker-сеть
docker network create proxy
# Собрать и запустить
docker compose up -d --build
# Убедиться, что всё работает
docker compose logs -f
```
Приложение доступно: **http://localhost:80**
### Пересборка после изменений
```bash
# Пересобрать только backend
docker compose up -d --build backend
# Пересобрать только frontend
docker compose up -d --build frontend
```
### Полный сброс данных
```bash
docker compose down -v # Удаляет БД
docker compose up -d # Пересоздаёт с нуля
```
---
## Соглашения о коде
### Java (Backend)
#### Именование
| Категория | Стиль | Пример |
|-----------|-------|--------|
| Классы | PascalCase | `LessonsController`, `LessonResponse` |
| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` |
| Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` |
| Пакеты | lowercase | `com.magistr.app.controller` |
#### Архитектурные правила
- **Constructor Injection** — все зависимости через конструктор (не `@Autowired` на поля)
- **Controller → Repository** — контроллеры работают напрямую с репозиториями (без слоя service)
- **Префикс `/api/`** — все REST-эндпоинты
- **`ResponseEntity<?>`** — все мутирующие методы возвращают `ResponseEntity` с HTTP-статусом
- **Сообщения на русском** — все ошибки и уведомления на русском языке
#### Логирование
Используйте SLF4J:
```java
private static final Logger logger = LoggerFactory.getLogger(MyController.class);
// Информационные сообщения
logger.info("Запрос на получение всех занятий");
// Ошибки с полным стектрейсом
logger.error("Ошибка при сохранении: {}", e.getMessage(), e);
```
#### Валидация
- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`)
- Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()`
#### Импорты
```java
// 1. Static imports
import static org.junit.Assert.*;
// 2. Java/Jakarta
import java.util.*;
import jakarta.persistence.*;
// 3. External libraries
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
// 4. Internal packages (wildcard для того же модуля)
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
```
#### Форматирование
- **Отступы:** 4 пробела
- **Скобки:** K&R style (открывающая на той же строке)
- **Длина строки:** до 120 символов
- **Фигурные скобки** обязательны для `if`/`for`/`while`
### JavaScript (Frontend)
#### Именование
| Категория | Стиль | Пример |
|-----------|-------|--------|
| Файлы | kebab-case | `main.js`, `schedule-view.js` |
| Функции и переменные | camelCase | `loadUsers()`, `pageTitle` |
| Константы | UPPER_SNAKE_CASE | `API_BASE_URL` |
#### Модули
- ES6 Modules с `import`/`export`
- **Всегда указывать расширение:** `import { api } from './api.js';`
#### Лучшие практики
```javascript
// ✅ Предпочитайте const
const token = localStorage.getItem('token');
// ✅ Async/await вместо .then()
async function loadData() {
try {
const data = await api.get('/api/users');
} catch (e) {
console.error('Ошибка:', e.message);
}
}
// ✅ Template literals
const msg = `Найдено ${items.length} записей`;
// ✅ Деструктуризация
const { id, name, role } = user;
```
#### Форматирование
- **Отступы:** 4 пробела
- **Кавычки:** одинарные `'`
- **Точки с запятой:** обязательны
---
## Создание нового эндпоинта (пошагово)
### 1. Модель (если нужна новая таблица)
Создайте Flyway миграцию `V{N}__{description}.sql`:
```sql
-- backend/src/main/resources/db/migration/V3__add_absences.sql
CREATE TABLE IF NOT EXISTS absences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
reason VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL
);
```
Создайте JPA-сущность:
```java
@Entity
@Table(name = "absences")
public class Absence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
```
### 2. Репозиторий
```java
public interface AbsenceRepository extends JpaRepository<Absence, Long> {
List<Absence> findByTeacherId(Long teacherId);
}
```
### 3. DTO (опционально)
```java
public record AbsenceResponse(Long id, String teacherName, String reason) {}
```
### 4. Контроллер
```java
@RestController
@RequestMapping("/api/absences")
public class AbsenceController {
private final AbsenceRepository absenceRepository;
public AbsenceController(AbsenceRepository absenceRepository) {
this.absenceRepository = absenceRepository;
}
@GetMapping
public List<Absence> getAll() {
return absenceRepository.findAll();
}
}
```
---
## Работа с миграциями Flyway
### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения
### Применение
```bash
# Локально — сброс и повтор всех миграций
docker compose down -v && docker compose up -d
# Продакшн — применить к существующим тенантам
kubectl rollout restart deployment backend -n magistr
```
---
## Структура пакетов (Backend)
```
com.magistr.app/
├── Application.java # Точка входа
├── config/
│ ├── AppConfig.java # Бины (BCryptPasswordEncoder)
│ ├── DataInitializer.java # Инициализация данных
│ └── tenant/ # Мультитенантность
│ ├── TenantConfig.java # POJO конфигурации тенанта
│ ├── TenantContext.java # ThreadLocal текущего тенанта
│ ├── TenantInterceptor.java # Определение тенанта из Host
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
│ ├── TenantDataSourceConfig.java # Spring-конфигурация
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
├── controller/ # REST-контроллеры
│ ├── AuthController.java
│ ├── LessonsController.java
│ ├── ClassroomController.java
│ ├── DatabaseController.java
│ ├── UserController.java
│ ├── GroupController.java
│ ├── SubjectController.java
│ ├── EquipmentController.java
│ ├── EducationFormController.java
│ └── TeacherSubjectController.java
├── dto/ # Data Transfer Objects
├── model/ # JPA-сущности
├── repository/ # Spring Data JPA
└── utils/ # Валидаторы
├── DayAndWeekValidator.java
└── TypeAndFormatLessonValidator.java
```

256
docs/FRONTEND.md Normal file
View File

@@ -0,0 +1,256 @@
# 🎨 Frontend
## Общая информация
| Параметр | Значение |
|----------|----------|
| **Фреймворк** | Нет (Vanilla JavaScript) |
| **Модульная система** | ES6 Modules (`import`/`export`) |
| **Стили** | CSS (модульный подход) |
| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) |
| **Веб-сервер** | Apache httpd:alpine |
---
## Структура файлов
```
frontend/
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
├── theme-toggle.js # Переключение светлой/тёмной темы
├── Dockerfile # httpd:alpine
├── admin/ # 👨‍💼 Интерфейс администратора
│ ├── index.html # SPA-оболочка с sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, цвета, типографика
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна
│ │ ├── department.css # Стили кафедры
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
│ │ ├── users.js # Управление пользователями
│ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами
│ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения
│ │ ├── schedule.js # Расписание занятий
│ │ ├── database.js # Управление тенантами
│ │ ├── department.js # Кафедры
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений
│ │ ├── users.html
│ │ ├── groups.html
│ │ ├── classrooms.html
│ │ ├── subjects.html
│ │ ├── equipments.html
│ │ ├── edu-forms.html
│ │ ├── schedule.html
│ │ ├── database.html
│ │ ├── department.html
│ │ └── departments-data.html
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, базовые стили
│ │ └── layout.css # Sidebar, topbar, content
│ ├── js/
│ │ └── main.js # Навигация по вкладкам настроек
│ └── views/
│ └── general.html # Общие настройки (заглушка)
├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ └── index.html # Просмотр расписания
└── student/ # 🎓 Интерфейс студента
└── index.html # Просмотр расписания (read-only)
```
---
## Система маршрутизации (Admin SPA)
Админ-панель работает как **Single Page Application** без фреймворка.
Навигация реализована через `data-tab` атрибуты на элементах sidebar:
```html
<a href="#" class="nav-item" data-tab="users">Пользователи</a>
<a href="#" class="nav-item" data-tab="groups">Группы</a>
<a href="#" class="nav-item" data-tab="schedule">Расписание занятий</a>
```
При клике на пункт меню `main.js`:
1. Загружает HTML-шаблон из `views/{tab}.html` через `fetch()`
2. Вставляет его в `#app-content`
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
4. Обновляет заголовок страницы (`#page-title`)
### Разделы админ-панели
| Tab | Описание | API |
|-----|----------|-----|
| `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп | `/api/groups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `schedule` | Расписание | `/api/users/lessons` |
| `database` | Тенанты | `/api/database` |
| `department` | Кафедры | `/api/departments` |
| `departments-data` | Создание кафедры/специальности | `/api/departments` |
### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
- Доступ: через dropdown «Настройки» в footer боковой панели админки
- Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки:
- **Общие настройки** — заглушка (в разработке)
---
## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`:
```javascript
export async function apiFetch(endpoint, method = 'GET', body = null) {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
});
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return await response.json();
}
// Shortcut-методы
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
put: (url, body) => apiFetch(url, 'PUT', body),
delete: (url, body) => apiFetch(url, 'DELETE', body)
};
```
Токен берётся из `localStorage.getItem('token')`.
---
## Аутентификация (Frontend)
### Страница входа (`/index.html`)
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`:
- `token` — UUID-токен
- `role` — роль пользователя
4. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/`
- `TEACHER``/teacher/`
- `STUDENT``/student/`
### Проверка авторизации
На каждой странице проверяется наличие токена и роли:
```javascript
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return token && role === 'ADMIN';
}
```
### Выход
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
---
## CSS-архитектура
### Модульный подход
Стили разделены на модульные файлы (порядок подключения важен):
1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема
2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive
3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle
4. **`modals.css`** — Модальные окна
5. **`department.css`** — Стили страницы кафедр
6. **`departments-data.css`** — Стили создания кафедры/специальности
### Темизация
CSS-переменные позволяют поддерживать светлую/тёмную тему:
```css
:root {
--bg-primary: #ffffff;
--text-primary: #1a1a2e;
--accent: #6366f1;
}
[data-theme="dark"] {
--bg-primary: #0f0f23;
--text-primary: #e2e8f0;
--accent: #818cf8;
}
```
Переключение — через `theme-toggle.js`.
---
## Боковая панель (Sidebar)
- **Скрытие/раскрытие** — кнопка-крестик в правом верхнем углу sidebar
- **Десктоп** (`>768px`): sidebar складывается влево, контент расширяется; состояние сохраняется в `localStorage` (`sidebar-collapsed`)
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
---
## OpenTelemetry (`otel.js`)
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`.
- **На production** — загружается автоматически через динамический `import()`
- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh`
```javascript
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
```
---
## Адаптивность
Интерфейс адаптирован под мобильные устройства:
- Sidebar скрывается на экранах < 768px, выезжает как overlay
- Появляется кнопка-гамбургер (`#menu-toggle`)
- Кнопка-крестик закрывает sidebar на всех устройствах
- Таблицы получают горизонтальный скролл

137
docs/INFRASTRUCTURE.md Normal file
View File

@@ -0,0 +1,137 @@
# 🏭 Инфраструктура
## Docker Compose (локальная разработка)
### Сервисы
```yaml
services:
backend: # Spring Boot (Java 17), порт 8080
frontend: # Apache httpd:alpine, порт 80
db: # PostgreSQL alpine3.23, порт 5432
```
### Сеть
Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском:
```bash
docker network create proxy
```
### Переменные окружения
Файл `.env` в корне проекта:
```env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword
```
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine``mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
### Dockerfile (Frontend)
```dockerfile
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
```
---
## Kubernetes (продакшн)
### Расположение конфигурации
Файлы Kubernetes манифестов: `../k8s/`
### Ключевые ресурсы
| Ресурс | Тип | Описание |
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов |
### ConfigMap для тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
При добавлении тенанта через API:
1. `DatabaseController` обновляет in-memory DataSource
2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API
3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек)
### Обновление backend
```bash
kubectl rollout restart deployment backend -n magistr
```
---
## Caddy (реверс-прокси)
**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис
В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`:
- Автоматическое получение TLS-сертификатов (Let's Encrypt)
- Маршрутизация `/api/*` → backend:8080
- Маршрутизация статики → frontend:80
---
## CI/CD (Gitea Actions)
### Пайплайн сборки Docker-образов
Расположение: `.gitea/workflows/docker-build.yaml`
Основные шаги:
1. Checkout кода
2. Login в Docker Registry
3. Build + Push образов (`backend`, `frontend`)
4. Генерация меток через `docker/metadata-action`
---
## Мониторинг (SigNoz + OpenTelemetry)
### Архитектура мониторинга
```mermaid
graph LR
Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector
Collector --> SigNoz["SigNoz"]
Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"]
```
### Интеграция Backend
Backend отправляет через OpenTelemetry:
- **Логи** — через Logback + OTLP exporter
- **Трейсы** — автоинструментация Spring Boot
- **Метрики** — JVM метрики, HTTP метрики
Tenant ID добавляется в:
- MDC (логи): `MDC.put("tenant.id", tenant)`
- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)`
### Интеграция Frontend
Файл `admin/js/otel.js` — клиентская телеметрия:
- Метрики производительности страниц
- Трейсы пользовательских действий
### Дашборды SigNoz
- JVM Dashboard (Heap, GC, Threads)
- PostgreSQL Dashboard (Connections, Queries)
- HTTP Dashboard (Requests, Latency, Errors)

167
docs/LOGGING.md Normal file
View File

@@ -0,0 +1,167 @@
# 📋 Логирование
## Стек технологий
| Компонент | Технология |
|-----------|------------|
| Фасад | SLF4J (`org.slf4j.Logger`) |
| Реализация | Logback (поставляется с `spring-boot-starter-web`) |
| Конфигурация | Стандартная Spring Boot (без кастомного `logback.xml`) |
| Экспорт (прод) | OpenTelemetry Java Agent → OTLP → SigNoz |
| Контекст тенанта | SLF4J MDC (`tenant.id`) |
---
## Архитектура
```mermaid
graph LR
Code["Java-код<br/>log.info(...)"] --> SLF4J["SLF4J API"]
SLF4J --> Logback["Logback"]
Logback -->|"Локальная разработка"| Console["stdout / stderr"]
Logback -->|"Продакшн"| OTelAgent["OTel Java Agent<br/>(Logback Appender)"]
OTelAgent -->|"OTLP HTTP"| SigNoz["SigNoz"]
```
### Локальная разработка
Логи выводятся в `stdout` контейнера в стандартном формате Spring Boot:
```
2026-03-22 12:00:00.123 INFO 1 --- [main] c.m.app.config.DataInitializer : Initializing databases for 1 tenant(s)...
```
Просмотр логов:
```bash
docker compose logs -f backend
```
### Продакшн (Kubernetes)
OpenTelemetry Java Agent подключается как `-javaagent` в [Dockerfile](file:///mnt/HDD/magistr/magistr/backend/Dockerfile) и автоматически перехватывает логи Logback, экспортируя их в SigNoz по OTLP.
```dockerfile
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]
```
Конфигурация агента задаётся через переменные окружения в [backend.yaml](file:///mnt/HDD/magistr/k8s/backend.yaml):
| Переменная | Значение | Назначение |
|------------|----------|------------|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://192.168.1.100:4318` | Адрес SigNoz Collector |
| `OTEL_SERVICE_NAME` | `magistr-backend` | Имя сервиса в SigNoz |
| `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=default` | Окружение |
| `OTEL_LOGS_EXPORTER` | `otlp` | Экспорт логов через OTLP |
| `OTEL_METRICS_EXPORTER` | `otlp` | Экспорт метрик через OTLP |
| `OTEL_TRACES_EXPORTER` | `otlp` | Экспорт трейсов через OTLP |
| `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES` | `tenant.id` | Захват MDC-атрибута в логи |
> [!NOTE]
> В локальной разработке OpenTelemetry Agent также встроен в Docker-образ, но без переменных `OTEL_*` он работает в режиме noop — логи идут только в stdout.
---
## Мультитенантный контекст (MDC)
Каждый HTTP-запрос обогащается tenant ID через [TenantInterceptor](file:///mnt/HDD/magistr/magistr/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java):
```java
// preHandle — при входе запроса
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
// afterCompletion — после завершения
MDC.remove("tenant.id");
```
Это позволяет:
- Фильтровать логи по тенанту в SigNoz
- Коррелировать логи с трейсами через Span-атрибуты
- Идентифицировать, к какому университету относится каждая запись
---
## Использование в коде
### Классы с логированием
| Класс | Уровни | Что логируется |
|-------|--------|----------------|
| `TenantInterceptor` | DEBUG, WARN | Резолвинг тенанта, неизвестный тенант (404) |
| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 |
| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения |
| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции |
| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s |
| `DataInitializer` | INFO | Инициализация БД при старте |
| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация |
### Паттерн использования
```java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyClass {
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
public void doSomething() {
log.info("Операция выполнена: param={}", value);
log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом
}
}
```
### Рекомендации по уровням
| Уровень | Когда использовать |
|---------|-------------------|
| `ERROR` | Необработанные ошибки, сбои подключения к БД, провалы миграций |
| `WARN` | Неизвестный тенант, нет конфигурации, fallback-сценарии |
| `INFO` | Успешные операции, CRUD-действия, старт/стоп компонентов |
| `DEBUG` | Детали резолвинга тенанта, ping-запросы |
---
## Настройка уровня логирования
В [application.properties](file:///mnt/HDD/magistr/magistr/backend/src/main/resources/application.properties) (по умолчанию закомментировано):
```properties
# Включить DEBUG для всего приложения
#logging.level.root=DEBUG
# Только для пакета приложения
logging.level.com.magistr.app=DEBUG
# Только для конкретного класса
logging.level.com.magistr.app.config.tenant.TenantInterceptor=DEBUG
```
Также можно задавать через переменные окружения:
```bash
LOGGING_LEVEL_ROOT=DEBUG
LOGGING_LEVEL_COM_MAGISTR_APP=DEBUG
```
---
## Просмотр логов
### Локально (Docker Compose)
```bash
# Все логи backend
docker compose logs -f backend
# Фильтрация по ключевому слову
docker compose logs -f backend | grep "tenant"
```
### Продакшн (SigNoz)
Логи доступны в веб-интерфейсе SigNoz → раздел **Logs**:
- Фильтрация по `service.name = magistr-backend`
- Фильтрация по `tenant.id` (из MDC)
- Корреляция с трейсами через общий `trace_id`

113
docs/README.md Normal file
View File

@@ -0,0 +1,113 @@
# 📚 Magistr — Система управления университетским расписанием
## Обзор
**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом.
- **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company)
- **Локальная разработка:** [http://localhost:80](http://localhost:80)
---
## Стек технологий
| Компонент | Технология |
|-----------|-----------|
| **Backend** | Java 17, Spring Boot 3.2.5 |
| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS |
| **База данных** | PostgreSQL (через Flyway миграции) |
| **Контейнеризация** | Docker, Docker Compose |
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
| **Мониторинг** | SigNoz, OpenTelemetry |
| **CI/CD** | Gitea Actions |
---
## Быстрый старт
### Предварительные требования
- Docker и Docker Compose
- Git
### Локальный запуск
```bash
# 1. Клонировать репозиторий
git clone <repo-url> magistr && cd magistr
# 2. Создать Docker-сеть (если ещё не создана)
docker network create proxy
# 3. Запустить все сервисы
docker compose up -d --build
```
После запуска приложение доступно по адресу: **http://localhost:80**
**Учётные данные по умолчанию:**
| Логин | Пароль | Роль |
|-------|--------|------|
| `admin` | `admin` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
### Полезные команды
```bash
# Просмотр логов
docker compose logs -f backend
# Полный сброс базы данных (удаление данных + повтор миграций)
docker compose down -v
docker compose up -d
# Остановка всех сервисов
docker compose down
```
---
## Структура проекта
```
magistr/
├── backend/ # Java Spring Boot backend
│ └── src/main/
│ ├── java/com/magistr/app/
│ │ ├── controller/ # REST-контроллеры (10 шт.)
│ │ ├── model/ # JPA-сущности
│ │ ├── dto/ # Data Transfer Objects
│ │ ├── repository/ # Spring Data JPA репозитории
│ │ ├── config/ # Конфигурация приложения
│ │ │ └── tenant/ # Мультитенантность
│ │ └── utils/ # Валидаторы
│ └── resources/
│ ├── application.properties
│ └── db/migration/ # Flyway SQL миграции
├── frontend/ # Статический фронтенд
│ ├── index.html # Страница авторизации
│ ├── admin/ # Админ-панель (деканат)
│ │ ├── js/views/ # Модули представлений
│ │ └── css/ # Стили
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация (вы здесь)
├── compose.yaml # Docker Compose конфигурация
├── .env # Переменные окружения
└── AGENTS.md # Руководство для AI-агентов
```
---
## 📖 Навигация по документации
| Документ | Содержание |
|----------|-----------|
| [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов |
| [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами |
| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции |
| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов |
| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг |
| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков |
| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили |

115
docs/UI_COMPONENTS.md Normal file
View File

@@ -0,0 +1,115 @@
# 🎨 Использование UI компонентов: Выпадающие списки (Dropdowns)
В проекте Magistr используется **премиальная кастомная дизайн-система** выпадающих списков. В связи с ограничениями браузеров на стилизацию стандартных элементов `<select>`, мы реализовали два типа компонентов, которые выглядят потрясающе (с эффектом glassmorphism, встроенными микро-анимациями и свечением), но интегрируются максимально просто.
---
## 1. Стандартные одинарные списки (Custom Select Wrapper)
Этот компонент автоматически "оборачивает" любые стандартные теги `<select>` на всём сайте, превращая их в красивые выпадающие меню. Вам **не нужно** писать сложный HTML, всё работает автоматически!
### Как добавить новый одинарный список:
Просто добавьте обычный тег `<select>` в HTML:
```html
<div class="form-group">
<label for="my-new-select">Выберите опцию</label>
<select id="my-new-select">
<option value="">Выберите...</option>
<option value="1">Опция 1</option>
<option value="2">Опция 2</option>
</select>
</div>
```
### Как это работает:
1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**.
2. Как только любой скрипт или загрузка страницы добавляет `<select>` в DOM, скрипт автоматически:
- Скрывает оригинальный `<select>` (но оставляет его доступным из JS!).
- Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона.
- Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`).
### Динамическое обновление списка (через JS):
Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно:
```javascript
const select = document.getElementById('my-new-select');
select.innerHTML = '<option value="99">Новое значение с API</option>';
```
**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `<option>`, поэтому он **автоматически перестроит красивый кастомный выпадающий список**. Никаких дополнительных вызовов для перерисовки не требуется.
---
## 2. Множественный выбор (Multi-Select с чекбоксами)
Этот UI-компонент позволяет выбирать сразу несколько элементов из выпадающего списка. Он включает в себя кастомные красивые галочки (checkmarks) с неоновой подсветкой и кастомный скроллбар.
Этот компонент требует написания определённой HTML-структуры, так как нативного тега `select multiple` с похожей функциональностью не существует.
### Как добавить мульти-селект:
**1. HTML Структура:**
```html
<div class="form-group">
<label>Выберите оборудование</label>
<div class="custom-multi-select">
<!-- Кнопка-триггер (то, на что нажимаем) -->
<div class="select-box" id="my-multi-box">
<span class="select-text" id="my-multi-text">Выберите...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- Само выпадающее меню -->
<div class="dropdown-menu" id="my-multi-menu">
<div id="my-multi-checkboxes" class="checkbox-group-vertical">
<!-- Сюда JS добавит чекбоксы -->
</div>
</div>
</div>
</div>
```
**2. Инициализация (в вашем JS-файле):**
Используйте готовую утилиту `initMultiSelect` из `utils.js` (она обрабатывает клики и открытие/закрытие):
```javascript
import { initMultiSelect } from '../utils.js';
// Передаем ID: box, menu, text, container
initMultiSelect('my-multi-box', 'my-multi-menu', 'my-multi-text', 'my-multi-checkboxes');
```
**3. Рендеринг элементов с кастомными галочками:**
Чтобы нарисовать сами чекбоксы, нужно использовать класс `.checkbox-item` и обязательный пустой `span.checkmark`. Пример генерации HTML:
```javascript
const container = document.getElementById('my-multi-checkboxes');
const items = [{id: 1, name: "Проектор"}, {id: 2, name: "Компьютер"}];
container.innerHTML = items.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<!-- Обязательный элемент для красивой галочки: -->
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
```
### Как прочитать выбранные значения:
Просто соберите массив value у выбранных чекбоксов внутри контейнера:
```javascript
const checkedBoxes = Array.from(document.querySelectorAll('#my-multi-checkboxes input:checked'));
const selectedIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
console.log(selectedIds); // [1, 2]
```
---
## Итог и правила
1. **Никогда не пытайтесь "красить" нативные теги `<option>`.** Браузеры (особенно Safari и Chrome) не позволяют этого сделать.
2. Для **отдельного выбора (1 из N)** всегда используйте стандартный `select`. Наша обёртка сделает всю магию сама.
3. Для **множественного выбора (N из M)** используйте HTML-шаблон `.custom-multi-select` (с `span.checkmark`).

0
frontend/.dockerignore Normal file → Executable file
View File

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