Compare commits
106 Commits
personal-s
...
dynamic_sc
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fde2566d | |||
|
|
f3a9905423 | ||
|
|
7926038bdc | ||
|
|
e2a3553a09 | ||
| e989391c8d | |||
| 5b6ffa5c08 | |||
|
|
bbfee068da | ||
|
|
9df67400b8 | ||
|
|
c19a9b34c7 | ||
|
|
37850b8e06 | ||
|
|
e9575cf2ce | ||
|
|
c7cb5125f4 | ||
|
|
d4ace6aab2 | ||
|
|
87cd4cbdc7 | ||
| 52328ae6c0 | |||
| d2ef44471b | |||
|
|
467f78c3d0 | ||
|
|
f08db3f178 | ||
|
|
c506a905a2 | ||
|
|
f49fa95d5a | ||
|
|
b1b85c3d17 | ||
|
|
91729cff7d | ||
|
|
f010ffc467 | ||
|
|
670e0a51ad | ||
|
|
02c5ea9118 | ||
|
|
e12a5bcccd | ||
|
|
b222f3adac | ||
|
|
44cfe8ab6e | ||
|
|
3cb311f469 | ||
|
|
bf02efb8b8 | ||
|
|
89c822a073 | ||
|
|
96e9d8155f | ||
|
|
8f71b9b2b5 | ||
|
|
eb023c07fb | ||
|
|
d2b9d0c5ea | ||
|
|
813e81be70 | ||
|
|
e92aa74048 | ||
|
|
48e8d4e631 | ||
|
|
c7145de95a | ||
|
|
c7594c4380 | ||
| ac69a57290 | |||
| c82e3feaed | |||
|
|
3cdb8614cb | ||
|
|
73995f86f8 | ||
|
|
cd6cc6f5f7 | ||
| 2be2534a1e | |||
| b14d937062 | |||
| 9d06c99d06 | |||
|
|
522bc97b8c | ||
|
|
d0a8148fa0 | ||
|
|
0b9d063266 | ||
|
|
6f33e23e17 | ||
|
|
bfdcb58c7d | ||
|
|
e015758caf | ||
|
|
6be8db0cd0 | ||
|
|
7a2c385257 | ||
| f7483e7aeb | |||
|
|
55da934545 | ||
|
|
e71bcee9b5 | ||
| 7ce0d1e501 | |||
|
|
3861fa05b5 | ||
|
|
599e284ea9 | ||
|
|
ec7e615557 | ||
|
|
9e7b35aa0b | ||
|
|
4915e6f33b | ||
|
|
798d61c7ea | ||
|
|
e03a68b7a8 | ||
|
|
fcd7baac71 | ||
|
|
491807cd94 | ||
|
|
0817961d97 | ||
|
|
49ca2e17b6 | ||
|
|
c07e49ca98 | ||
|
|
b89d1c7f72 | ||
| 6774ebb766 | |||
| f7fb524bb0 | |||
|
|
81e91e056f | ||
| d78e675a71 | |||
|
|
8cf086d3e9 | ||
| f39c3d1bbb | |||
|
|
dc1c343174 | ||
| 74fcd07e25 | |||
|
|
8ced8ae669 | ||
|
|
f519650bbb | ||
|
|
7fac9f744d | ||
|
|
18d099460d | ||
|
|
59b6704be9 | ||
|
|
220b99594f | ||
|
|
c10198515c | ||
|
|
a8144acb8b | ||
|
|
04feb5a3c3 | ||
|
|
d69eab1c12 | ||
|
|
f3ea05cd17 | ||
|
|
9f124c52a5 | ||
|
|
10c06e726a | ||
|
|
9d2de1faaf | ||
|
|
59caa9d6cc | ||
|
|
bad1215341 | ||
|
|
ccdc371c3a | ||
|
|
4c2293b620 | ||
|
|
6ea420e529 | ||
|
|
75b1ad166e | ||
|
|
abad3776db | ||
|
|
13b3a5c481 | ||
|
|
3579ef9f1c | ||
|
|
14cc006f06 | ||
| 9e55472de7 |
85
.agents/skills/AutoUpdateDocs.md
Normal file
85
.agents/skills/AutoUpdateDocs.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: AutoUpdateDocs
|
||||
description: Автоматическое обновление документации проекта после изменений в коде
|
||||
---
|
||||
|
||||
# Скилл: Автоматическое обновление документации
|
||||
|
||||
## Когда активировать
|
||||
|
||||
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
|
||||
|
||||
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
|
||||
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
|
||||
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
|
||||
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
|
||||
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
|
||||
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
|
||||
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
|
||||
- **Общая структура проекта** → обновить `docs/README.md`
|
||||
|
||||
## Карта соответствия «файл → документация»
|
||||
|
||||
| Изменённый файл/директория | Файл документации |
|
||||
|----------------------------|-------------------|
|
||||
| `controller/*Controller.java` | `docs/API.md` |
|
||||
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
|
||||
| `model/*.java` | `docs/DATABASE.md` |
|
||||
| `dto/*.java` | `docs/API.md` |
|
||||
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
|
||||
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
|
||||
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
|
||||
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
|
||||
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
|
||||
| `application.properties` | `docs/ARCHITECTURE.md` |
|
||||
|
||||
## Пошаговая инструкция
|
||||
|
||||
### 1. Определить затронутые файлы документации
|
||||
|
||||
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
|
||||
|
||||
### 2. Прочитать текущую документацию
|
||||
|
||||
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
|
||||
|
||||
### 3. Внести точечные изменения
|
||||
|
||||
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
|
||||
|
||||
#### Новый контроллер → `docs/API.md`
|
||||
Добавить новую секцию с описанием эндпоинтов:
|
||||
- Метод + URL
|
||||
- Тело запроса (JSON пример)
|
||||
- Ответ (JSON пример)
|
||||
- Валидация
|
||||
|
||||
#### Новая миграция → `docs/DATABASE.md`
|
||||
- Добавить новую таблицу в ER-диаграмму (Mermaid)
|
||||
- Добавить описание таблицы и колонок
|
||||
- Добавить запись в таблицу «Текущие миграции»
|
||||
|
||||
#### Новый view → `docs/FRONTEND.md`
|
||||
- Добавить в дерево файлов
|
||||
- Добавить в таблицу «Разделы админ-панели»
|
||||
|
||||
### 4. Обновить AGENTS.md (при необходимости)
|
||||
|
||||
Если изменения затрагивают:
|
||||
- Структуру директорий → обновить дерево в `AGENTS.md`
|
||||
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
|
||||
|
||||
### 5. Сообщить пользователю
|
||||
|
||||
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
|
||||
|
||||
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Язык:** Вся документация на русском языке
|
||||
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
|
||||
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
|
||||
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
|
||||
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
|
||||
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения
|
||||
86
.agents/skills/auto-update-docs/SKILL.md
Normal file
86
.agents/skills/auto-update-docs/SKILL.md
Normal 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/`, обновить все вхождения
|
||||
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
177
.agents/skills/frontend-design/LICENSE.txt
Normal 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
|
||||
42
.agents/skills/frontend-design/SKILL.md
Normal file
42
.agents/skills/frontend-design/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Создание выразительных, готовых к продакшену frontend-интерфейсов с высоким качеством дизайна. Используйте этот навык, когда пользователь просит разработать веб-компоненты, страницы, артефакты, постеры или приложения (например, сайты, лендинги, дашборды, React-компоненты, HTML/CSS верстку или когда нужно стилизовать/улучшить любой веб-интерфейс). Генерирует креативный, отточенный код и UI-дизайн, избегая шаблонной эстетики ИИ.
|
||||
license: Полные условия в LICENSE.txt
|
||||
---
|
||||
|
||||
Этот навык направляет создание выразительных, готовых к продакшену frontend-интерфейсов, которые избегают шаблонной "ИИ-эстетики". Создавайте реально работающий код с исключительным вниманием к эстетическим деталям и творческим решениям.
|
||||
|
||||
Пользователь предоставляет требования к фронтенду: компонент, страницу, приложение или интерфейс для разработки. Требования могут включать контекст о цели, аудитории или технических ограничениях.
|
||||
|
||||
## Дизайн-мышление
|
||||
|
||||
Перед написанием кода поймите контекст и примите СМЕЛОЕ эстетическое направление:
|
||||
- **Цель**: Какую проблему решает этот интерфейс? Кто им пользуется?
|
||||
- **Тон**: Выберите крайность: брутальный минимализм, максималистский хаос, ретро-футуризм, органический/природный, люксовый/утонченный, игривый/игрушечный, редакционный/журнальный, брутализм/грубый, арт-деко/геометрический, мягкий/пастельный, индустриальный/утилитарный и т.д. Вариантов очень много. Используйте их для вдохновения, но создайте дизайн, верный выбранному эстетическому направлению.
|
||||
- **Ограничения**: Технические требования (фреймворк, производительность, доступность).
|
||||
- **Отличительная черта**: Что делает это НЕЗАБЫВАЕМЫМ? Какую единственную вещь кто-то запомнит?
|
||||
|
||||
**КРИТИЧЕСКИ ВАЖНО**: Выберите четкое концептуальное направление и выполните его с точностью. Смелый максимализм и утонченный минимализм — оба работают, ключ кроется в осознанности намерений, а не в интенсивности.
|
||||
|
||||
Затем реализуйте рабочий код (HTML/CSS/JS, React, Vue и т.д.), который:
|
||||
- Готов к продакшену и функционален
|
||||
- Визуально поразителен и легко запоминается
|
||||
- Согласован с четкой эстетической точкой зрения
|
||||
- Тщательно проработан в каждой детали
|
||||
|
||||
## Руководство по эстетике фронтенда
|
||||
|
||||
Сфокусируйтесь на:
|
||||
- **Типографика**: Выбирайте шрифты, которые красивы, уникальны и интересны. Избегайте общих шрифтов, таких как Arial и Inter; вместо этого делайте выбор в пользу выразительных, неожиданных и характерных вариантов, которые повышают уровень эстетики фронтенда. Сочетайте акцидентный шрифт (display) с утонченным текстовым (body).
|
||||
- **Цвет и тема**: Придерживайтесь согласованной эстетики. Используйте CSS-переменные для консистентности. Доминирующие цвета с резкими акцентами работают намного лучше, чем робкие, равномерно распределенные палитры.
|
||||
- **Анимация (Motion)**: Используйте анимации для эффектов и микро-взаимодействий. Отдавайте предпочтение CSS-решениям для HTML. Используйте библиотеки анимаций для React, если они доступны. Фокусируйтесь на моментах с высоким влиянием: одна хорошо срежиссированная загрузка страницы с каскадным появлением элементов (animation-delay) создает больше восторга, чем множество разрозненных микро-взаимодействий. Используйте триггеры при скролле (scroll-triggering) и состояния наведения (hover), которые удивляют.
|
||||
- **Пространственная композиция**: Неожиданные макеты. Асимметрия. Перекрытие. Диагональное направление. Элементы, ломающие сетку. Обильное негативное пространство ИЛИ контролируемая плотность элементов.
|
||||
- **Фоны и визуальные детали**: Создавайте атмосферу и глубину вместо использования скучных сплошных цветов по умолчанию. Добавляйте контекстуальные эффекты и текстуры, соответствующие общей эстетике. Применяйте творческие формы: градиентные сетки, шумовые текстуры, геометрические паттерны, слоистые прозрачности, драматичные тени, декоративные рамки, кастомные курсоры и эффекты зернистости (grain).
|
||||
|
||||
НИКОГДА не используйте шаблонную сгенерированную ИИ эстетику: заезженные семейства шрифтов (Inter, Roboto, Arial, системные шрифты), клишированные цветовые схемы (особенно фиолетовые градиенты на белом фоне), предсказуемые макеты и паттерны компонентов, а также типовой скучный дизайн без характера, не учитывающий контекст.
|
||||
|
||||
Интерпретируйте творчески и делайте неожиданные выборы, которые кажутся действительно разработанными под данный контекст. Ни один дизайн не должен быть шаблонным ("под копирку"). Варьируйте между светлыми и темными темами, разными шрифтами, различной эстетикой. НИКОГДА не сходитесь к общим выборам (например, Space Grotesk) в разных генерациях кода.
|
||||
|
||||
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
|
||||
|
||||
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.
|
||||
21
.codex/config.toml
Normal file
21
.codex/config.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
#:schema https://developers.openai.com/codex/config-schema.json
|
||||
|
||||
web_search = "live"
|
||||
|
||||
developer_instructions = """
|
||||
Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
|
||||
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
|
||||
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
|
||||
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
|
||||
"""
|
||||
|
||||
[skills]
|
||||
include_instructions = true
|
||||
|
||||
[[skills.config]]
|
||||
path = "../.agents/skills/auto-update-docs"
|
||||
enabled = true
|
||||
|
||||
[[skills.config]]
|
||||
path = "../.agents/skills/frontend-design"
|
||||
enabled = true
|
||||
0
.gitea/workflows/docker-build.yaml
Normal file → Executable file
0
.gitea/workflows/docker-build.yaml
Normal file → Executable file
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
db/data/
|
||||
.env
|
||||
backend/tenants.json
|
||||
|
||||
# Игнорируем временные файлы сборки (на будущее)
|
||||
backend/target/
|
||||
@@ -7,9 +8,7 @@ backend/build/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
.agents
|
||||
.idea/
|
||||
.vscode/
|
||||
*.DS_Store
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
skills-lock.json
|
||||
|
||||
91
AGENTS.md
Executable file
91
AGENTS.md
Executable file
@@ -0,0 +1,91 @@
|
||||
# AGENTS.md - Руководство для агентных помощников
|
||||
|
||||
## Обзор проекта
|
||||
|
||||
Проект представляет собой систему управления университетским расписанием.
|
||||
- **Backend**: Java 17, Spring Boot 3.2.5 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
|
||||
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
||||
- **Локальный URL**: localhost:80
|
||||
- **Продакшн URL**: https://magistr.zuev.company
|
||||
|
||||
---
|
||||
|
||||
## Структура директорий
|
||||
|
||||
```
|
||||
magistr/
|
||||
├── backend/ # Java Spring Boot приложение
|
||||
│ └── src/main/java/com/magistr/app/
|
||||
│ ├── controller/ # REST контроллеры
|
||||
│ ├── model/ # JPA сущности
|
||||
│ ├── dto/ # Data Transfer Objects
|
||||
│ ├── repository/ # Spring Data JPA репозитории
|
||||
│ ├── config/ # Конфигурация приложения
|
||||
│ ├── utils/ # Утилиты
|
||||
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
||||
├── frontend/ # Статические файлы
|
||||
│ ├── admin/ # Интерфейс администратора
|
||||
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
||||
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
|
||||
│ ├── teacher/ # Интерфейс преподавателя
|
||||
│ └── student/ # Интерфейс студента
|
||||
├── docs/ # 📖 Документация проекта
|
||||
├── compose.yaml # Docker Compose конфигурация
|
||||
└── .env # Переменные окружения
|
||||
```
|
||||
|
||||
**Внешние зависимости (родительская директория)**
|
||||
|
||||
На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый справочник команд
|
||||
|
||||
```bash
|
||||
# Сборка и запуск
|
||||
docker compose up -d --build
|
||||
|
||||
# Полный сброс БД
|
||||
docker compose down -v && docker compose up -d
|
||||
|
||||
# Логи конкретного сервиса
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Критические правила для агентов
|
||||
|
||||
### Flyway миграции
|
||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||
|
||||
### Языковые требования
|
||||
- **Все ответы и комментарии на русском языке**
|
||||
- Сообщения об ошибках и логи на русском
|
||||
- Пользовательский интерфейс на русском
|
||||
|
||||
---
|
||||
|
||||
## Подробная документация
|
||||
|
||||
Полная документация проекта находится в папке `docs/`:
|
||||
|
||||
| Документ | Содержание |
|
||||
|----------|-----------|
|
||||
| [`docs/README.md`](docs/README.md) | Обзор проекта, стек технологий, быстрый старт |
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Архитектура системы, мультитенантность, аутентификация |
|
||||
| [`docs/BUSINESS_LOGIC.md`](docs/BUSINESS_LOGIC.md) | Бизнес-логика, ролевая модель, правила расписания |
|
||||
| [`docs/DATABASE.md`](docs/DATABASE.md) | Схема БД (ER-диаграмма), описание всех таблиц, Flyway |
|
||||
| [`docs/API.md`](docs/API.md) | REST API эндпоинты с примерами запросов и ответов |
|
||||
| [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) |
|
||||
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта |
|
||||
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность |
|
||||
| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz |
|
||||
| [`docs/UI_COMPONENTS.md`](docs/UI_COMPONENTS.md) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |
|
||||
248
SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md
Normal file
248
SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Отчёт об изменениях по задачам научного руководителя
|
||||
|
||||
Дата: 2026-05-19
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Выполнен первый цельный релиз архитектурной основы:
|
||||
|
||||
- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`;
|
||||
- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`;
|
||||
- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`;
|
||||
- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю;
|
||||
- добавлена история переводов преподавателей между кафедрами;
|
||||
- добавлены точечные изменения расписания: перенос, отмена, замена;
|
||||
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
|
||||
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
|
||||
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
|
||||
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
|
||||
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
|
||||
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
|
||||
- обновлена документация проекта.
|
||||
|
||||
## Backend
|
||||
|
||||
### Роли и авторизация
|
||||
|
||||
Поддерживаются роли:
|
||||
|
||||
- `ADMIN`;
|
||||
- `EDUCATION_OFFICE`;
|
||||
- `DEPARTMENT`;
|
||||
- `SCHEDULE_VIEWER`;
|
||||
- `TEACHER`;
|
||||
- `STUDENT`.
|
||||
|
||||
Добавлены:
|
||||
|
||||
- `AuthSessionService` — in-memory UUID-сессии;
|
||||
- `AuthContext` — текущий пользователь в `ThreadLocal`;
|
||||
- `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`;
|
||||
- `@RequireRoles` — ограничение доступа на уровне контроллеров и методов;
|
||||
- `GET /api/auth/me` — текущий пользователь по токену.
|
||||
|
||||
Redirect после входа:
|
||||
|
||||
- `ADMIN` -> `/admin/`;
|
||||
- `EDUCATION_OFFICE` -> `/admin/#schedule-view`;
|
||||
- `DEPARTMENT` -> `/admin/#department-workspace`;
|
||||
- `SCHEDULE_VIEWER` -> `/admin/#schedule-view`;
|
||||
- `TEACHER` -> `/teacher/`;
|
||||
- `STUDENT` -> `/student/`.
|
||||
|
||||
### Миграция БД
|
||||
|
||||
Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта.
|
||||
|
||||
Все изменения схемы внесены в:
|
||||
|
||||
```text
|
||||
backend/src/main/resources/db/migration/V1__init.sql
|
||||
```
|
||||
|
||||
`V1__init.sql` теперь сразу создаёт:
|
||||
|
||||
- lifecycle-поля к справочникам и пользователям;
|
||||
- `teacher_department_assignments`;
|
||||
- `subject_comments`;
|
||||
- version-поля к `schedule_rules`;
|
||||
- lock-поля к `schedule_rule_slots`;
|
||||
- `schedule_overrides`;
|
||||
- стартовых пользователей `учебный_отдел`, `кафедра_иб` и `просмотр_расписаний`.
|
||||
|
||||
### Архивирование вместо удаления
|
||||
|
||||
Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания.
|
||||
|
||||
Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки.
|
||||
|
||||
### История кафедр преподавателя
|
||||
|
||||
Добавлены endpoint:
|
||||
|
||||
- `GET /api/users/{id}/department-history`;
|
||||
- `POST /api/users/{id}/department-transfer`;
|
||||
- `GET /api/users/teachers/by-department/{departmentId}?date=YYYY-MM-DD`.
|
||||
|
||||
При переводе преподавателя старая запись закрывается, новая открывается с `valid_from`, а `users.department_id` обновляется как текущая кафедра.
|
||||
|
||||
### Расписание и загруженность
|
||||
|
||||
Добавлены:
|
||||
|
||||
- `GET /api/schedule/search`;
|
||||
- `GET /api/workload/teachers`;
|
||||
- `GET /api/workload/classrooms`;
|
||||
- `GET /api/workload/departments`;
|
||||
- `GET /api/workload/time-slots`;
|
||||
- `GET /api/workload/free-classrooms`;
|
||||
- `GET/POST/PUT/DELETE /api/edu-office/schedule/overrides`.
|
||||
|
||||
`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания.
|
||||
|
||||
Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам.
|
||||
|
||||
### Кабинет кафедры API
|
||||
|
||||
Добавлены:
|
||||
|
||||
- `GET /api/department/subjects`;
|
||||
- `POST /api/department/subjects/import`;
|
||||
- `GET /api/department/subjects/{subjectId}/comments`;
|
||||
- `POST /api/department/subjects/{subjectId}/comments`;
|
||||
- `GET /api/department/teachers`;
|
||||
- `GET /api/department/schedule`.
|
||||
|
||||
Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Единая панель по ролям
|
||||
|
||||
Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель:
|
||||
|
||||
- `/department/` -> `/admin/#department-workspace`;
|
||||
- `/edu-office/` -> `/admin/#schedule-view`.
|
||||
|
||||
В `frontend/admin/js/main.js` добавлена фильтрация вкладок:
|
||||
|
||||
- `ADMIN` видит все вкладки;
|
||||
- `EDUCATION_OFFICE` видит просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории и оборудование;
|
||||
- `DEPARTMENT` видит кабинет кафедры и просмотр расписаний;
|
||||
- `SCHEDULE_VIEWER` видит только просмотр расписаний.
|
||||
|
||||
Backend не опирается только на скрытие вкладок. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, а `@RequireRoles` ограничивает операции на контроллерах и методах. Для кафедры дополнительно закрыты обходные пути:
|
||||
|
||||
- общий `/api/subjects` оставлен для записи только администратору, кафедра загружает дисциплины через scoped `/api/department/subjects/import`;
|
||||
- `/api/teacher-subjects` для роли `DEPARTMENT` проверяет, что и преподаватель, и дисциплина относятся к кафедре текущего пользователя;
|
||||
- `SCHEDULE_VIEWER` имеет только read-only доступ к расписаниям и справочникам-фильтрам.
|
||||
|
||||
После браузерной проверки исправлено визуальное скрытие недоступных вкладок: `layout.css` теперь принудительно скрывает `.nav-item[hidden]` и скрытые пункты меню настроек, поэтому роли больше не видят чужие вкладки в sidebar.
|
||||
|
||||
### Просмотр расписаний
|
||||
|
||||
Добавлена вкладка:
|
||||
|
||||
```text
|
||||
frontend/admin/views/schedule-view.html
|
||||
frontend/admin/js/views/schedule-view.js
|
||||
```
|
||||
|
||||
Возможности:
|
||||
|
||||
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
|
||||
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
|
||||
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
|
||||
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
|
||||
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
|
||||
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
|
||||
|
||||
### Загруженность
|
||||
|
||||
Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
|
||||
|
||||
Возможности:
|
||||
|
||||
- выбор типа загруженности: аудитории, преподаватели или кафедры;
|
||||
- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
|
||||
- кафедральная матрица группирует занятия по кафедре преподавателя;
|
||||
- для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
|
||||
- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
|
||||
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
|
||||
|
||||
### Кабинет кафедры
|
||||
|
||||
Добавлена вкладка:
|
||||
|
||||
```text
|
||||
frontend/admin/views/department-workspace.html
|
||||
frontend/admin/js/views/department-workspace.js
|
||||
```
|
||||
|
||||
Возможности:
|
||||
|
||||
- просмотр дисциплин кафедры;
|
||||
- загрузка дисциплин из списка `код; название`;
|
||||
- просмотр и добавление комментариев к дисциплинам;
|
||||
- просмотр преподавателей кафедры;
|
||||
- просмотр нагрузки преподавателей кафедры за период.
|
||||
|
||||
### Админка
|
||||
|
||||
Обновлено:
|
||||
|
||||
- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`;
|
||||
- удаление пользователей заменено на архивирование;
|
||||
- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию;
|
||||
- удаление аудитории заменено на вывод из эксплуатации;
|
||||
- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`.
|
||||
|
||||
## Документация
|
||||
|
||||
Обновлены:
|
||||
|
||||
- `AGENTS.md`;
|
||||
- `docs/README.md`;
|
||||
- `docs/API.md`;
|
||||
- `docs/DATABASE.md`;
|
||||
- `docs/BUSINESS_LOGIC.md`;
|
||||
- `docs/FRONTEND.md`;
|
||||
- `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Проверки
|
||||
|
||||
Статически проверены новые и изменённые JS-файлы:
|
||||
|
||||
```bash
|
||||
node --check frontend/admin/js/main.js
|
||||
node --check frontend/admin/js/views/schedule-view.js
|
||||
node --check frontend/admin/js/views/auditorium-workload.js
|
||||
node --check frontend/admin/js/views/department-workspace.js
|
||||
node --check frontend/admin/settings/js/main.js
|
||||
node --check frontend/admin/js/api.js
|
||||
node --check frontend/admin/js/views/users.js
|
||||
node --check frontend/admin/js/views/classrooms.js
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Backend-компиляция проверяется через Docker Maven:
|
||||
|
||||
```bash
|
||||
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
|
||||
```
|
||||
|
||||
Результат: компиляция прошла успешно.
|
||||
|
||||
Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
|
||||
|
||||
Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
|
||||
|
||||
## Что осталось следующим этапом
|
||||
|
||||
Остались задачи, которые требуют отдельного цикла проработки:
|
||||
|
||||
- UI для просмотра истории переводов преподавателя в админке;
|
||||
- UI для списка и редактирования уже созданных `schedule_overrides`;
|
||||
- более строгая проверка конфликтов перед сохранением переносов;
|
||||
- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
|
||||
- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.
|
||||
288
analysis_results.md
Normal file
288
analysis_results.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Анализ структуры вкладок и предложения по UX/UI оптимизации системы Magistr
|
||||
|
||||
В данном документе представлен детальный анализ текущей структуры интерфейса административной панели (SPA) и настроек системы Magistr. Выявлены пересекающиеся области функционала, сформулированы предложения по их объединению, переносу в настройки или удалению, а также предложены новые функциональные модули и технические решения для оптимизации UX/UI и производительности.
|
||||
|
||||
---
|
||||
|
||||
## 1. Анализ текущей структуры и доступности вкладок
|
||||
|
||||
На данный момент в основном SPA-интерфейсе администратора (`/admin/`) зарегистрировано **13 вкладок**, а в дополнительном SPA настроек (`/admin/settings/`) — **2 вкладки**.
|
||||
|
||||
В зависимости от роли пользователя, сайдбар фильтруется следующим образом:
|
||||
|
||||
| Вкладка (ID) | Описание | Доступность по ролям |
|
||||
| :--- | :--- | :--- |
|
||||
| `users` | CRUD пользователей и архивирование | `ADMIN` |
|
||||
| `groups` | CRUD групп, подгрупп и назначений календарей | `ADMIN` |
|
||||
| `edu-forms` | Справочник форм обучения | `ADMIN` |
|
||||
| `profiles` | Управление профилями обучения специальностей | `ADMIN` |
|
||||
| `equipments` | Каталог оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `classrooms` | Список аудиторий с привязкой оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `subjects` | CRUD всех дисциплин и привязка преподавателей | `ADMIN` |
|
||||
| `department-workspace` | Рабочая область кафедры (дисциплины, импорт, нагрузка) | `ADMIN`, `DEPARTMENT` |
|
||||
| `schedule-view` | Просмотр расписаний (read-only) | `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER` |
|
||||
| `schedule` | Конструктор правил динамического расписания | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `academic-calendar` | Календарные графики и Excel-редактор сетки | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `auditorium-workload` | Матрицы и таблицы загруженности | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `database` | Управление тенантами (БД клиентов) | `ADMIN` |
|
||||
| **Settings** (SPA) | Общие настройки, Управление временными слотами | `ADMIN`, `EDUCATION_OFFICE` (ссылка в подвале) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Пересечения функционала и проблемные зоны
|
||||
|
||||
### 2.1. Избыточное дробление академической структуры
|
||||
Для описания структуры обучения сейчас используются четыре отдельные вкладки:
|
||||
1. `departments-data` (Создание кафедры/специальности)
|
||||
2. `profiles` (Профили обучения специальностей)
|
||||
3. `edu-forms` (Формы обучения)
|
||||
4. `groups` (В блоке создания группы выбираются форма, кафедра, специальность и профиль)
|
||||
|
||||
**В чём проблема:**
|
||||
* **Разрыв контекста:** Чтобы завести новую специальность с профилями, администратору сначала нужно пойти во вкладку `departments-data`, создать специальность, затем перейти во вкладку `profiles`, выбрать в селекте эту специальность и добавить профили.
|
||||
* **Перегрузка меню:** 13 вкладок в сайдбаре для роли `ADMIN` — это слишком много. Пользователю трудно ориентироваться визуально.
|
||||
|
||||
### 2.2. Дублирование и раздвоение управления дисциплинами
|
||||
Управление дисциплинами разнесено между вкладками `subjects` (для администратора) и `department-workspace` (для кафедры).
|
||||
|
||||
**В чём проблема:**
|
||||
* Администратор во вкладке `subjects` видит плоскую таблицу всех дисциплин всех кафедр, но не имеет удобных инструментов фильтрации по кафедре.
|
||||
* Форма создания новой дисциплины во вкладке `subjects` требует ввода **числового ID кафедры вручную** (поле `new-subject-department` типа `number`). Это критическая UX-ошибка: администратор не должен помнить числовые ID БД наизусть.
|
||||
* Привязка преподавателей к дисциплинам (`teacher_subjects`) находится на вкладке `subjects`. При этом сама нагрузка преподавателей и список преподавателей кафедры отображаются на вкладке `department-workspace`. Логичнее было бы видеть, какие дисциплины ведёт преподаватель, непосредственно при работе с его нагрузкой или в едином справочнике преподавателей/кафедры.
|
||||
|
||||
### 2.3. Низкая частота использования справочника оборудования (`equipments`)
|
||||
Оборудование существует только как подчиненная сущность для аудиторий (чтобы указать, что в аудитории 101 есть 20 ПК и 1 проектор).
|
||||
|
||||
**В чём проблема:**
|
||||
* Выделение целой вкладки под простой список («Проектор», «ПК») не оправдано. Список оборудования меняется раз в несколько лет.
|
||||
* При этом привязка оборудования к аудиториям происходит на вкладке `classrooms`.
|
||||
|
||||
### 2.4. Перегруженность вкладки групп (`groups`)
|
||||
На вкладке групп на одном экране выводятся сразу три тяжелые формы и три таблицы:
|
||||
1. Создание/редактирование групп + таблица групп.
|
||||
2. Создание подгрупп для лабораторных + таблица подгрупп.
|
||||
3. Назначение календарного графика группе + таблица назначений.
|
||||
|
||||
**В чём проблема:**
|
||||
* Экран перегружен элементами управления.
|
||||
* Для работы с подгруппами или назначениями графиков конкретной группы пользователю приходится выбирать эту группу из длинного выпадающего списка в отдельной форме, вместо того чтобы выполнять действия в контексте строки этой группы в таблице.
|
||||
|
||||
### 2.5. Нахождение системной вкладки «База данных» в общем меню расписания
|
||||
Вкладка `database` отвечает за управление тенантами (добавление новых вузов-клиентов, создание для них БД и запуск Flyway миграций).
|
||||
|
||||
**В чём проблема:**
|
||||
* Это технический функционал уровня Super-Admin / DevOps. Он не имеет отношения к ежедневному планированию расписания. Нахождение его в одном ряду с расписанием и группами нарушает логику интерфейса.
|
||||
|
||||
---
|
||||
|
||||
## 3. Рекомендации по оптимизации структуры (Что убрать, объединить и перенести)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Было (13 вкладок в меню ADMIN)
|
||||
direction TB
|
||||
T1[database]
|
||||
T2[edu-forms]
|
||||
T3[departments-data]
|
||||
T4[profiles]
|
||||
T5[equipments]
|
||||
T6[classrooms]
|
||||
T7[groups]
|
||||
T8[users]
|
||||
T9[subjects]
|
||||
T10[department-workspace]
|
||||
T11[schedule]
|
||||
T12[schedule-view]
|
||||
T13[academic-calendar]
|
||||
T14[auditorium-workload]
|
||||
end
|
||||
|
||||
subgraph Стало (Сгруппированное меню + настройки)
|
||||
direction TB
|
||||
subgraph Основное меню SPA (Группы вкладок)
|
||||
direction TB
|
||||
DASH[1. Дашборд / Главная]
|
||||
|
||||
subgraph Модуль: Расписание
|
||||
SCH_ED[2. Конструктор расписания]
|
||||
SCH_VI[3. Просмотр расписаний]
|
||||
CAL[4. Календарный график]
|
||||
WORK[5. Загруженность]
|
||||
end
|
||||
|
||||
subgraph Модуль: Структура
|
||||
UNIV[6. Структура вуза<br/>Кафедры, Специальности, Профили]
|
||||
GRP[7. Группы и подгруппы]
|
||||
SUBJ[8. Дисциплины]
|
||||
end
|
||||
|
||||
subgraph Модуль: Ресурсы
|
||||
CLASS[9. Аудиторный фонд<br/>Аудитории + Оборудование]
|
||||
end
|
||||
|
||||
USR[10. Пользователи]
|
||||
end
|
||||
|
||||
subgraph SPA Настройки (Настройки системы)
|
||||
direction TB
|
||||
SET_GEN[Общие настройки]
|
||||
SET_TIME[Временные слоты сетка]
|
||||
SET_FORM[Формы обучения из edu-forms]
|
||||
SET_DB[Базы данных / Тенанты из database]
|
||||
end
|
||||
end
|
||||
|
||||
T1 -->|Перенести| SET_DB
|
||||
T2 -->|Перенести| SET_FORM
|
||||
T3 -->|Объединить| UNIV
|
||||
T4 -->|Объединить| UNIV
|
||||
T5 -->|Объединить| CLASS
|
||||
T6 -->|Объединить| CLASS
|
||||
T7 -->|Оптимизировать| GRP
|
||||
T8 -->|Оставить| USR
|
||||
T9 -->|Оптимизировать| SUBJ
|
||||
T10 -->|Сохранить для роли Кафедра| SUBJ
|
||||
T11 -->|Оставить| SCH_ED
|
||||
T12 -->|Оставить| SCH_VI
|
||||
T13 -->|Оставить| CAL
|
||||
T14 -->|Оставить| WORK
|
||||
```
|
||||
|
||||
### 3.1. Перенос в настройки (`/admin/settings/`)
|
||||
1. **База данных (`database`)**: Перенести в SPA настроек в раздел «Управление тенантами». Доступ должен быть только у роли `ADMIN`.
|
||||
2. **Формы обучения (`edu-forms`)**: Перенести в настройки. Данный справочник заполняется один раз и не требует оперативного доступа из главного меню.
|
||||
|
||||
### 3.2. Объединение вкладок
|
||||
1. **Объединить Кафедры, Специальности и Профили** в одну вкладку **«Структура вуза»** (`university-structure`):
|
||||
* Интерфейс делится на две вкладки внутри страницы: **«Кафедры»** и **«Специальности»**.
|
||||
* Управление профилями должно происходить прямо внутри списка специальностей. Например, в строке специальности в таблице добавляется кнопка «Профили», открывающая список профилей этой специальности в раскрывающейся строке (accordion) или модальном окне.
|
||||
2. **Объединить Аудитории (`classrooms`) и Оборудование (`equipments`)** в одну вкладку **«Аудиторный фонд»**:
|
||||
* Основной экран показывает список аудиторий.
|
||||
* Рядом располагается кнопка «Управление каталогом оборудования», которая открывает модальное окно с простым списком (добавить/удалить тип оборудования). Это избавит от необходимости держать под это отдельную вкладку в сайдбаре.
|
||||
|
||||
### 3.3. Реорганизация вкладки групп (`groups`)
|
||||
* **Оставить на вкладке только список групп** с возможностью фильтрации по курсу, кафедре и форме обучения.
|
||||
* **Вынести подгруппы и назначения календарей из глобальных форм:**
|
||||
* В строке таблицы для каждой группы добавить действия:
|
||||
* `[Подгруппы]` — открывает компактное модальное окно для быстрого создания/редактирования подгрупп (лабораторных) именно для этой группы.
|
||||
* `[Календарь]` — открывает модальное окно назначения календарного учебного графика на учебные годы для этой группы.
|
||||
* Это уберет лишние тяжелые формы с главной страницы и исключит необходимость выбора группы из селектов вручную.
|
||||
|
||||
### 3.4. Оптимизация Дисциплин (`subjects`) и Кабинета кафедры
|
||||
* **Исправить форму добавления дисциплины:** Заменить ручной числовой ввод ID кафедры (`new-subject-department`) на стандартный выпадающий список (выбор из загруженных кафедр).
|
||||
* **Связать привязку преподавателей с дисциплинами:** Вместо двух разрозненных таблиц на вкладке `subjects` («Все дисциплины» и «Привязки преподавателей»), объединить их:
|
||||
* В строке дисциплины выводить список привязанных преподавателей (в виде чипов/badge).
|
||||
* По клику на кнопку «Преподаватели» в строке дисциплины открывать модальное окно привязки/отвязки преподавателей для этой конкретной дисциплины.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что необходимо добавить (Новый функционал)
|
||||
|
||||
### 4.1. Главная страница / Дашборд (`dashboard`)
|
||||
При входе в систему администратор и сотрудники учебного отдела должны видеть не сырые таблицы пользователей или расписания, а высокоуровневую аналитическую панель:
|
||||
* **Информационные карточки (Метрики):**
|
||||
* Всего активных групп / студентов.
|
||||
* Количество задействованных преподавателей.
|
||||
* Процент занятости аудиторного фонда на текущий день.
|
||||
* Количество запланированных пар на сегодня.
|
||||
* **Мониторинг расписания в реальном времени:**
|
||||
* Список идущих прямо сейчас пар (текущий временной слот).
|
||||
* Ближайшие свободные аудитории.
|
||||
* **Быстрый переход:** Кнопки быстрого создания правила расписания, добавления переноса/замены.
|
||||
|
||||
### 4.2. Центр предупреждений и конфликтов (Red Zone)
|
||||
В соответствии с планируемой бизнес-логикой (раздел 5 `docs/BUSINESS_LOGIC.md`), система должна автоматически валидировать расписание и предупреждать о коллизиях.
|
||||
Необходима отдельная вкладка или виджет на дашборде **«Конфликты расписания»**:
|
||||
* **Критические ошибки (Блокирующие генерацию):**
|
||||
* *Накладки преподавателей:* Преподаватель назначен в один и тот же временной слот в разные аудитории / к разным группам (кроме потоковых лекций).
|
||||
* *Накладки аудиторий:* Две разные пары стоят в одной аудитории в один слот.
|
||||
* **Предупреждения (Мягкие проверки):**
|
||||
* *Превышение вместимости:* Численность группы (или сумма подгрупп при совмещенных занятиях) превышает физическую вместимость аудитории.
|
||||
* *Недостаток оборудования:* Дисциплина (например, лабораторная по химии) требует определенного оборудования, которого нет в назначенной аудитории.
|
||||
* *Превышение нагрузки:* Преподаватель превысил недельный лимит часов.
|
||||
|
||||
---
|
||||
|
||||
## 5. Концепция новой структуры меню (Сайдбара)
|
||||
|
||||
Для уменьшения визуального шума сайдбар предлагается сделать **двухуровневым** (сворачиваемые группы) или четко разделить визуальными разделителями:
|
||||
|
||||
```
|
||||
[ Логотип Magistr ]
|
||||
---------------------------
|
||||
👤 Кабинет кафедры <-- (Показывается только для роли DEPARTMENT)
|
||||
📈 Дашборд <-- (Новый главный экран для ADMIN / EDU_OFFICE)
|
||||
|
||||
📁 РАСПИСАНИЕ
|
||||
🗓 Конструктор правил
|
||||
🗓 Точечные изменения <-- (Выделенный функционал оверрайдов/переносов)
|
||||
📅 Календарный график
|
||||
📊 Загруженность
|
||||
🔍 Просмотр расписаний
|
||||
|
||||
🗂 СУБЪЕКТЫ И РЕСУРСЫ
|
||||
🏫 Аудиторный фонд <-- (Объединенные Аудитории + Оборудование)
|
||||
🎓 Группы обучения <-- (Группы + модалки подгрупп/календарей)
|
||||
🏛 Структура вуза <-- (Кафедры + Специальности + Профили)
|
||||
📚 Дисциплины <-- (Дисциплины + модалки преподавателей)
|
||||
|
||||
⚙️ СИСТЕМА
|
||||
👥 Пользователи
|
||||
⚙️ Настройки <-- (Переход в настройки SPA: слоты, БД, формы обучения)
|
||||
---------------------------
|
||||
[ Профиль / Выход ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Идеи по оптимизации фронтенда и взаимодействия с API
|
||||
|
||||
### 6.1. Кэширование справочников на клиенте
|
||||
На данный момент при переключении почти каждой вкладки заново запрашиваются списки кафедр, специальностей, учебных годов и т.д.
|
||||
* **Решение:** Реализовать простой кэш-менеджер во фронтенд-клиенте (`api.js` или отдельный `cache.js`):
|
||||
```javascript
|
||||
const cache = new Map();
|
||||
export async function getCachedData(key, fetchPromise) {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, await fetchPromise);
|
||||
}
|
||||
return cache.get(key);
|
||||
}
|
||||
export function clearCache(key) {
|
||||
if (key) cache.delete(key);
|
||||
else cache.clear();
|
||||
}
|
||||
```
|
||||
Это позволит избежать десятков дублирующих запросов при переключении между вкладками `groups`, `subjects`, `schedule`.
|
||||
|
||||
### 6.2. Ленивая инициализация вкладок (Lazy Loading)
|
||||
Сейчас в `main.js` импортируются все JS-модули вкладок одновременно при загрузке страницы:
|
||||
```javascript
|
||||
import { initUsers } from './views/users.js';
|
||||
import { initGroups } from './views/groups.js';
|
||||
// ...еще 10 импортов
|
||||
```
|
||||
* **Решение:** Перейти на динамический импорт модулей инициализации при переходе на конкретную вкладку:
|
||||
```javascript
|
||||
async function switchTab(tab) {
|
||||
// ...
|
||||
const viewModule = await import(`./views/${tab}.js`);
|
||||
viewModule.init();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Это уменьшит размер начального JS-бандла и ускорит первую загрузку страницы.
|
||||
|
||||
### 6.3. Единый реестр кастомных выпадающих списков
|
||||
В проекте реализованы кастомные селекты (выпадающие списки с поддержкой поиска и чекбоксов). При динамической подгрузке HTML-шаблонов часто требуется их повторная инициализация.
|
||||
* **Решение:** Создать глобальный шину событий (Event Bus) или MutationObserver, который автоматически подхватывает элементы с классами `.custom-select` или атрибутами `data-select` и инициализирует их без вызова ручного кода `initAllCustomDropdowns()` в каждом отдельном скрипте представления.
|
||||
|
||||
---
|
||||
|
||||
## Резюме
|
||||
|
||||
Предложенные изменения позволят:
|
||||
1. Сократить количество пунктов основного меню с **13** до **8-9** логически сгруппированных разделов.
|
||||
2. Исключить ручной ввод ID (заменив на селекты) и разгрузить перегруженные интерфейсы (выносом подгрупп и назначений в контекстные модальные окна групп).
|
||||
3. Повысить отзывчивость интерфейса за счет клиентского кэширования справочников и ленивой загрузки JS-модулей.
|
||||
4. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
|
||||
@@ -4,9 +4,16 @@ COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -B
|
||||
COPY src ./src
|
||||
RUN mvn package -DskipTests -B
|
||||
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
|
||||
# Best practice: run as a non-root user
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
USER spring:spring
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/app.jar app.jar
|
||||
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway Database Migrations -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
@@ -43,6 +49,33 @@
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT/JWS support without enabling full Spring Security auto-flow -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenTelemetry API for custom span attributes -->
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-api</artifactId>
|
||||
<version>1.49.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.magistr.app;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
|
||||
@EnableScheduling
|
||||
public class Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
package com.magistr.app.config;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* При запуске приложения инициализирует БД для каждого тенанта.
|
||||
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
|
||||
*/
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantConfigWatcher configWatcher;
|
||||
|
||||
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
public DataInitializer(TenantRoutingDataSource routingDataSource,
|
||||
TenantConfigWatcher configWatcher) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.configWatcher = configWatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Optional<User> existing = userRepository.findByUsername("admin");
|
||||
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
|
||||
|
||||
if (existing.isEmpty()) {
|
||||
User admin = new User();
|
||||
admin.setUsername("admin");
|
||||
admin.setPassword(passwordEncoder.encode("admin"));
|
||||
admin.setRole(Role.ADMIN);
|
||||
userRepository.save(admin);
|
||||
log.info("Created default admin user");
|
||||
} else {
|
||||
User admin = existing.get();
|
||||
if (!passwordEncoder.matches("admin", admin.getPassword())) {
|
||||
admin.setPassword(passwordEncoder.encode("admin"));
|
||||
admin.setRole(Role.ADMIN);
|
||||
userRepository.save(admin);
|
||||
log.info("Reset admin password (hash was invalid)");
|
||||
} else {
|
||||
log.info("Admin user already exists with correct password");
|
||||
}
|
||||
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
|
||||
configWatcher.initDatabaseForTenant(tenant);
|
||||
}
|
||||
|
||||
log.info("Database initialization complete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
public final class AuthContext {
|
||||
|
||||
private static final ThreadLocal<AuthenticatedUser> CURRENT_USER = new ThreadLocal<>();
|
||||
|
||||
private AuthContext() {
|
||||
}
|
||||
|
||||
public static void setCurrentUser(AuthenticatedUser user) {
|
||||
CURRENT_USER.set(user);
|
||||
}
|
||||
|
||||
public static AuthenticatedUser getCurrentUser() {
|
||||
return CURRENT_USER.get();
|
||||
}
|
||||
|
||||
public static Long currentUserId() {
|
||||
AuthenticatedUser user = getCurrentUser();
|
||||
return user == null ? null : user.id();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CURRENT_USER.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
|
||||
public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) {
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.model.Role;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class AuthorizationInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
|
||||
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
if (isPublicAuthEndpoint(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String token = bearerToken(request.getHeader("Authorization"));
|
||||
AuthenticatedUser user = jwtTokenService.authenticate(
|
||||
token,
|
||||
com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
|
||||
).orElse(null);
|
||||
if (user == null) {
|
||||
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthContext.setCurrentUser(user);
|
||||
RequireRoles roles = resolveRoles(handler);
|
||||
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
|
||||
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isPublicAuthEndpoint(HttpServletRequest request) {
|
||||
if (!request.getRequestURI().startsWith("/api/auth/")) {
|
||||
return false;
|
||||
}
|
||||
return "POST".equalsIgnoreCase(request.getMethod())
|
||||
&& (request.getRequestURI().equals("/api/auth/login")
|
||||
|| request.getRequestURI().equals("/api/auth/refresh")
|
||||
|| request.getRequestURI().equals("/api/auth/logout"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
private RequireRoles resolveRoles(Object handler) {
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return null;
|
||||
}
|
||||
RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class);
|
||||
if (methodRoles != null) {
|
||||
return methodRoles;
|
||||
}
|
||||
return handlerMethod.getBeanType().getAnnotation(RequireRoles.class);
|
||||
}
|
||||
|
||||
private String bearerToken(String header) {
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
return header.substring("Bearer ".length()).trim();
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
|
||||
response.setStatus(status);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
objectMapper.writeValue(response.getOutputStream(), Map.of("message", message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
|
||||
private Duration accessTtl = Duration.ofMinutes(15);
|
||||
private Duration refreshTtl = Duration.ofDays(7);
|
||||
private String refreshCookieName = "magistr_refresh";
|
||||
private boolean refreshCookieSecure = false;
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public Duration getAccessTtl() {
|
||||
return accessTtl;
|
||||
}
|
||||
|
||||
public void setAccessTtl(Duration accessTtl) {
|
||||
this.accessTtl = accessTtl;
|
||||
}
|
||||
|
||||
public Duration getRefreshTtl() {
|
||||
return refreshTtl;
|
||||
}
|
||||
|
||||
public void setRefreshTtl(Duration refreshTtl) {
|
||||
this.refreshTtl = refreshTtl;
|
||||
}
|
||||
|
||||
public String getRefreshCookieName() {
|
||||
return refreshCookieName;
|
||||
}
|
||||
|
||||
public void setRefreshCookieName(String refreshCookieName) {
|
||||
this.refreshCookieName = refreshCookieName;
|
||||
}
|
||||
|
||||
public boolean isRefreshCookieSecure() {
|
||||
return refreshCookieSecure;
|
||||
}
|
||||
|
||||
public void setRefreshCookieSecure(boolean refreshCookieSecure) {
|
||||
this.refreshCookieSecure = refreshCookieSecure;
|
||||
}
|
||||
|
||||
public byte[] secretBytes() {
|
||||
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class JwtTokenService {
|
||||
|
||||
private static final String ISSUER = "magistr";
|
||||
|
||||
private final JwtProperties properties;
|
||||
private final JwtEncoder encoder;
|
||||
private final JwtDecoder decoder;
|
||||
|
||||
public JwtTokenService(JwtProperties properties) {
|
||||
this.properties = properties;
|
||||
SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256");
|
||||
this.encoder = new NimbusJwtEncoder(new ImmutableSecret<SecurityContext>(secretKey));
|
||||
this.decoder = NimbusJwtDecoder
|
||||
.withSecretKey(secretKey)
|
||||
.macAlgorithm(MacAlgorithm.HS256)
|
||||
.build();
|
||||
}
|
||||
|
||||
public String createAccessToken(User user, String tenant) {
|
||||
return createAccessToken(new AuthenticatedUser(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole(),
|
||||
user.getDepartmentId()
|
||||
), tenant);
|
||||
}
|
||||
|
||||
public String createAccessToken(AuthenticatedUser user, String tenant) {
|
||||
Instant now = Instant.now();
|
||||
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
|
||||
.issuer(ISSUER)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(properties.getAccessTtl()))
|
||||
.subject(String.valueOf(user.id()))
|
||||
.id(UUID.randomUUID().toString())
|
||||
.claim("tenant", tenant)
|
||||
.claim("userId", user.id())
|
||||
.claim("username", user.username())
|
||||
.claim("role", user.role().name());
|
||||
|
||||
if (user.departmentId() != null) {
|
||||
claims.claim("departmentId", user.departmentId());
|
||||
}
|
||||
|
||||
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
|
||||
return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue();
|
||||
}
|
||||
|
||||
public Optional<AuthenticatedUser> authenticate(String token, String expectedTenant) {
|
||||
if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
Jwt jwt = decoder.decode(token);
|
||||
String tenant = jwt.getClaimAsString("tenant");
|
||||
if (!expectedTenant.equalsIgnoreCase(tenant)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
|
||||
String username = jwt.getClaimAsString("username");
|
||||
Role role = Role.valueOf(jwt.getClaimAsString("role"));
|
||||
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
|
||||
|
||||
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Long> asLong(Object value) {
|
||||
if (value == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return Optional.of(number.longValue());
|
||||
}
|
||||
try {
|
||||
return Optional.of(Long.valueOf(String.valueOf(value)));
|
||||
} catch (NumberFormatException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.User;
|
||||
|
||||
public record RefreshTokenRotation(User user, String refreshToken) {
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthRefreshToken;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class RefreshTokenService {
|
||||
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
private static final int USER_AGENT_LIMIT = 512;
|
||||
private static final int IP_LIMIT = 64;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final JwtProperties properties;
|
||||
|
||||
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||
this.repository = repository;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||
String refreshToken = generateRawToken();
|
||||
AuthRefreshToken token = new AuthRefreshToken();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
token.setUser(user);
|
||||
token.setTenant(tenant);
|
||||
token.setTokenHash(hashToken(refreshToken));
|
||||
token.setIssuedAt(now);
|
||||
token.setExpiresAt(now.plus(properties.getRefreshTtl()));
|
||||
token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
|
||||
token.setIpAddress(limit(ipAddress, IP_LIMIT));
|
||||
repository.save(token);
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<RefreshTokenRotation> rotate(String rawToken, String tenant, String userAgent, String ipAddress) {
|
||||
if (rawToken == null || rawToken.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String currentHash = hashToken(rawToken);
|
||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
|
||||
if (storedOpt.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
AuthRefreshToken stored = storedOpt.get();
|
||||
if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
User user = stored.getUser();
|
||||
if (user.isArchivedRecord()) {
|
||||
stored.setRevokedAt(now);
|
||||
repository.save(stored);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String newRawToken = generateRawToken();
|
||||
String newHash = hashToken(newRawToken);
|
||||
stored.setRevokedAt(now);
|
||||
stored.setRotatedToTokenHash(newHash);
|
||||
repository.save(stored);
|
||||
|
||||
AuthRefreshToken next = new AuthRefreshToken();
|
||||
next.setUser(user);
|
||||
next.setTenant(tenant);
|
||||
next.setTokenHash(newHash);
|
||||
next.setIssuedAt(now);
|
||||
next.setExpiresAt(now.plus(properties.getRefreshTtl()));
|
||||
next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
|
||||
next.setIpAddress(limit(ipAddress, IP_LIMIT));
|
||||
repository.save(next);
|
||||
|
||||
return Optional.of(new RefreshTokenRotation(user, newRawToken));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revoke(String rawToken, String tenant) {
|
||||
if (rawToken == null || rawToken.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.findByTokenHash(hashToken(rawToken))
|
||||
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||
.filter(token -> token.getRevokedAt() == null)
|
||||
.ifPresent(token -> {
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
repository.save(token);
|
||||
});
|
||||
}
|
||||
|
||||
String hashToken(String rawToken) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 недоступен", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRawToken() {
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String limit(String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
public @interface RequireRoles {
|
||||
Role[] value();
|
||||
}
|
||||
125
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
125
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
@@ -0,0 +1,125 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
|
||||
*
|
||||
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
|
||||
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
|
||||
*/
|
||||
@Service
|
||||
public class ConfigMapUpdater {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
|
||||
|
||||
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
|
||||
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
|
||||
private static final String CONFIGMAP_NAME = "tenants-config";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final boolean runningInK8s;
|
||||
|
||||
public ConfigMapUpdater() {
|
||||
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||
if (!runningInK8s) {
|
||||
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет ConfigMap tenants-config с новым списком тенантов.
|
||||
* @return true если обновление успешно (или мы не в K8s)
|
||||
*/
|
||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||
if (!runningInK8s) {
|
||||
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
|
||||
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
|
||||
|
||||
// Формируем JSON для тенантов
|
||||
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(tenants);
|
||||
|
||||
// Strategic merge patch для ConfigMap
|
||||
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||
"data", Map.of("tenants.json", tenantsJson)
|
||||
));
|
||||
|
||||
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
|
||||
K8S_API_BASE, namespace, CONFIGMAP_NAME);
|
||||
|
||||
// Создаём HttpClient с отключённой проверкой сертификатов
|
||||
// (внутри кластера используется self-signed CA)
|
||||
HttpClient client = createInsecureClient();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
|
||||
return true;
|
||||
} else {
|
||||
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
|
||||
*/
|
||||
private HttpClient createInsecureClient() {
|
||||
try {
|
||||
TrustManager[] trustAll = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
|
||||
}
|
||||
};
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAll, new SecureRandom());
|
||||
|
||||
return HttpClient.newBuilder()
|
||||
.sslContext(sslContext)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
|
||||
return HttpClient.newHttpClient();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java
Executable file
41
backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java
Executable 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; }
|
||||
}
|
||||
162
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
162
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
@@ -0,0 +1,162 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Периодически перечитывает tenants.json (mounted ConfigMap).
|
||||
* Если ConfigMap был обновлён через K8s API, этот компонент
|
||||
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
||||
*
|
||||
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
||||
*/
|
||||
@Component
|
||||
public class TenantConfigWatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final DataSource dataSource;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||
private String lastConfigHash = "";
|
||||
|
||||
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||
public void watchForChanges() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) return;
|
||||
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
|
||||
if (hash.equals(lastConfigHash)) {
|
||||
return; // Ничего не изменилось
|
||||
}
|
||||
|
||||
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
||||
lastConfigHash = hash;
|
||||
|
||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||
syncTenants(newTenants);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
|
||||
*/
|
||||
public void refreshHash() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (file.exists()) {
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
lastConfigHash = configHash(content);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
static String configHash(String content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует in-memory тенантов с конфигом из файла.
|
||||
*/
|
||||
private void syncTenants(List<TenantConfig> newTenants) {
|
||||
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
|
||||
Set<String> newDomains = newTenants.stream()
|
||||
.map(t -> t.getDomain().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Добавить новые тенанты
|
||||
for (TenantConfig tenant : newTenants) {
|
||||
String domain = tenant.getDomain().toLowerCase();
|
||||
if (!current.containsKey(domain)) {
|
||||
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
|
||||
routingDataSource.addTenant(tenant);
|
||||
// Инициализируем БД для нового тенанта
|
||||
initDatabaseForTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить тенанты, которых больше нет в конфиге
|
||||
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||
if (!newDomains.contains(existingDomain)) {
|
||||
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
|
||||
routingDataSource.removeTenant(existingDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||
* Если БД уже существует, но история Flyway пуста —
|
||||
* делает baseline (считает V1_init.sql уже выполненным).
|
||||
*/
|
||||
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||
String domain = tenant.getDomain();
|
||||
try {
|
||||
TenantContext.setCurrentTenant(domain);
|
||||
|
||||
log.info("[{}] Запускаем миграции Flyway", domain);
|
||||
|
||||
// Получаем DataSource конкретно для этого тенанта
|
||||
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||
if (tenantDs == null) {
|
||||
// Если ещё не resolve'нулся (первый запуск), берём обёртку
|
||||
tenantDs = dataSource;
|
||||
}
|
||||
|
||||
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
|
||||
.dataSource(tenantDs)
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("1")
|
||||
.load();
|
||||
|
||||
flyway.migrate();
|
||||
log.info("[{}] Миграции Flyway успешно выполнены", domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java
Executable file
22
backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java
Executable 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();
|
||||
}
|
||||
}
|
||||
137
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
137
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
@@ -0,0 +1,137 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Конфигурация мультитенантного DataSource.
|
||||
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||
*
|
||||
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||
*/
|
||||
@Configuration
|
||||
public class TenantDataSourceConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
||||
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${spring.datasource.url:}")
|
||||
private String defaultDbUrl;
|
||||
|
||||
@Value("${spring.datasource.username:}")
|
||||
private String defaultDbUsername;
|
||||
|
||||
@Value("${spring.datasource.password:}")
|
||||
private String defaultDbPassword;
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource dataSource() {
|
||||
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||
|
||||
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||
|
||||
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||
TenantConfig defaultTenant = new TenantConfig(
|
||||
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||
);
|
||||
tenants.add(defaultTenant);
|
||||
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
|
||||
}
|
||||
|
||||
// Регистрируем тенантов
|
||||
for (TenantConfig tenant : tenants) {
|
||||
try {
|
||||
routingDataSource.addTenant(tenant);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||
|
||||
TenantConfig h2Fallback = new TenantConfig(
|
||||
"H2 Placeholder", "default",
|
||||
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||
"sa", ""
|
||||
);
|
||||
routingDataSource.addTenant(h2Fallback);
|
||||
}
|
||||
|
||||
return routingDataSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TenantRoutingDataSource tenantRoutingDataSource(DataSource dataSource) {
|
||||
return (TenantRoutingDataSource) dataSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
|
||||
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
|
||||
em.setDataSource(dataSource);
|
||||
em.setPackagesToScan("com.magistr.app.model");
|
||||
|
||||
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||
vendorAdapter.setGenerateDdl(false);
|
||||
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
||||
em.setJpaVendorAdapter(vendorAdapter);
|
||||
|
||||
Map<String, Object> props = new HashMap<>();
|
||||
props.put("hibernate.hbm2ddl.auto", "none");
|
||||
props.put("hibernate.show_sql", "false");
|
||||
em.setJpaPropertyMap(props);
|
||||
|
||||
return em;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
private List<TenantConfig> loadTenantsFromFile() {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) {
|
||||
log.info("Tenants config file not found: {}", tenantsConfigPath);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||
return list;
|
||||
} catch (IOException e) {
|
||||
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
106
backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java
Executable file
106
backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java
Executable 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";
|
||||
}
|
||||
}
|
||||
150
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file
150
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||
private final AuthorizationInterceptor authorizationInterceptor;
|
||||
|
||||
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
|
||||
AuthorizationInterceptor authorizationInterceptor) {
|
||||
this.tenantRoutingDataSource = tenantRoutingDataSource;
|
||||
this.authorizationInterceptor = authorizationInterceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TenantInterceptor tenantInterceptor() {
|
||||
TenantInterceptor interceptor = new TenantInterceptor();
|
||||
interceptor.setRoutingDataSource(tenantRoutingDataSource);
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/calendar")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class AcademicCalendarAdminController {
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping("/years")
|
||||
public List<AcademicYearDto> getYears() {
|
||||
return academicYearRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(AcademicYear::getStartDate).reversed())
|
||||
.map(this::toAcademicYearDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/years")
|
||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, request);
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
}
|
||||
|
||||
@PutMapping("/years/{id}")
|
||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applyYear(year, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/years/{id}")
|
||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||
if (!academicYearRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
academicYearRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||
}
|
||||
|
||||
@GetMapping("/years/{academicYearId}/semesters")
|
||||
public List<SemesterDto> getSemesters(@PathVariable Long academicYearId) {
|
||||
return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream()
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/years/{academicYearId}/semesters")
|
||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
|
||||
}
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
}
|
||||
|
||||
@PutMapping("/semesters/{id}")
|
||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
||||
if (semester == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
}
|
||||
|
||||
@GetMapping("/activity-types")
|
||||
public List<AcademicCalendarActivityTypeDto> getActivityTypes() {
|
||||
return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream()
|
||||
.map(this::toActivityTypeDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/activity-types")
|
||||
public ResponseEntity<?> createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) {
|
||||
String validationError = validateActivityType(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
|
||||
}
|
||||
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
|
||||
applyActivityType(activityType, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
|
||||
}
|
||||
|
||||
@PutMapping("/activity-types/{id}")
|
||||
public ResponseEntity<?> updateActivityType(@PathVariable Long id,
|
||||
@RequestBody AcademicCalendarActivityTypeDto request) {
|
||||
AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null);
|
||||
if (activityType == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateActivityType(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (activityTypeRepository.findByCode(request.code().trim())
|
||||
.filter(existing -> !existing.getId().equals(id))
|
||||
.isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
|
||||
}
|
||||
applyActivityType(activityType, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/activity-types/{id}")
|
||||
public ResponseEntity<?> deleteActivityType(@PathVariable Long id) {
|
||||
if (!activityTypeRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (calendarDayRepository.existsByActivityTypeId(id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках"));
|
||||
}
|
||||
activityTypeRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
|
||||
}
|
||||
|
||||
private String validateYear(AcademicYearDto request) {
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
return "Название учебного года обязательно";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания учебного года обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateSemester(SemesterDto request) {
|
||||
if (request.semesterType() == null) {
|
||||
return "Тип семестра обязателен";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания семестра обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания семестра не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте код активности";
|
||||
}
|
||||
if (request.code() == null || request.code().isBlank()) {
|
||||
return "Код активности обязателен";
|
||||
}
|
||||
if (request.name() == null || request.name().isBlank()) {
|
||||
return "Название активности обязательно";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year, AcademicYearDto request) {
|
||||
year.setTitle(request.title().trim());
|
||||
year.setStartDate(request.startDate());
|
||||
year.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
|
||||
activityType.setCode(request.code().trim());
|
||||
activityType.setName(request.name().trim());
|
||||
activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule()));
|
||||
activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank()
|
||||
? "#64748b"
|
||||
: request.colorCode().trim());
|
||||
activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder());
|
||||
activityType.setDescription(request.description() == null || request.description().isBlank()
|
||||
? null
|
||||
: request.description().trim());
|
||||
}
|
||||
|
||||
private AcademicYearDto toAcademicYearDto(AcademicYear year) {
|
||||
List<SemesterDto> semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId())
|
||||
.stream()
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters);
|
||||
}
|
||||
|
||||
private SemesterDto toSemesterDto(Semester semester) {
|
||||
return new SemesterDto(
|
||||
semester.getId(),
|
||||
semester.getAcademicYear().getId(),
|
||||
semester.getAcademicYear().getTitle(),
|
||||
semester.getSemesterType(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) {
|
||||
return new AcademicCalendarActivityTypeDto(
|
||||
activityType.getId(),
|
||||
activityType.getCode(),
|
||||
activityType.getName(),
|
||||
activityType.getAllowSchedule(),
|
||||
activityType.getColorCode(),
|
||||
activityType.getDisplayOrder(),
|
||||
activityType.getDescription()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/academic-calendars")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<AcademicCalendarDto> getCalendars(@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(required = false) Long specialtyId,
|
||||
@RequestParam(required = false) Long profileId) {
|
||||
return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream()
|
||||
.map(this::toCalendarDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
|
||||
return calendarRepository.findByIdWithDetails(id)
|
||||
.<ResponseEntity<?>>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
|
||||
try {
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
||||
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteCalendar(@PathVariable Long id) {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/grid")
|
||||
public ResponseEntity<?> getGrid(@PathVariable Long id) {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream()
|
||||
.map(this::toGridDayDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/grid")
|
||||
@Transactional
|
||||
public ResponseEntity<?> saveGrid(@PathVariable Long id, @RequestBody List<AcademicCalendarGridDayDto> rows) {
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика"));
|
||||
}
|
||||
|
||||
try {
|
||||
calendarDayRepository.deleteByCalendarId(id);
|
||||
for (AcademicCalendarGridDayDto row : rows) {
|
||||
AcademicCalendarDay day = buildGridDay(calendar, row);
|
||||
calendarDayRepository.save(day);
|
||||
}
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название графика обязательно");
|
||||
}
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
if (request.academicYearId() == null || request.specialtyId() == null
|
||||
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
|
||||
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
|
||||
}
|
||||
|
||||
AcademicYear year = academicYearRepository.findById(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
|
||||
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
|
||||
}
|
||||
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
|
||||
calendar.setTitle(request.title().trim());
|
||||
calendar.setAcademicYear(year);
|
||||
calendar.setSpeciality(speciality);
|
||||
calendar.setSpecialtyProfile(profile);
|
||||
calendar.setStudyForm(studyForm);
|
||||
calendar.setCourseCount(request.courseCount());
|
||||
}
|
||||
|
||||
private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) {
|
||||
if (row.courseNumber() == null || row.courseNumber() <= 0
|
||||
|| row.date() == null
|
||||
|| row.weekNumber() == null || row.weekNumber() <= 0
|
||||
|| row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
|
||||
}
|
||||
if (row.date().isBefore(calendar.getAcademicYear().getStartDate())
|
||||
|| row.date().isAfter(calendar.getAcademicYear().getEndDate())) {
|
||||
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
|
||||
}
|
||||
if (row.courseNumber() > calendar.getCourseCount()) {
|
||||
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
|
||||
}
|
||||
|
||||
AcademicCalendarActivityType activityType = resolveActivityType(row);
|
||||
AcademicCalendarDay day = new AcademicCalendarDay();
|
||||
day.setAcademicCalendar(calendar);
|
||||
day.setCourseNumber(row.courseNumber());
|
||||
day.setDate(row.date());
|
||||
day.setWeekNumber(row.weekNumber());
|
||||
day.setDayOfWeek(row.dayOfWeek());
|
||||
day.setActivityType(activityType);
|
||||
return day;
|
||||
}
|
||||
|
||||
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
|
||||
if (row.activityTypeId() != null) {
|
||||
return activityTypeRepository.findById(row.activityTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
|
||||
}
|
||||
if (row.activityCode() != null && !row.activityCode().isBlank()) {
|
||||
return activityTypeRepository.findByCode(row.activityCode().trim())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
|
||||
}
|
||||
throw new IllegalArgumentException("Код активности обязателен");
|
||||
}
|
||||
|
||||
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
|
||||
AcademicYear year = calendar.getAcademicYear();
|
||||
Speciality speciality = calendar.getSpeciality();
|
||||
SpecialtyProfile profile = calendar.getSpecialtyProfile();
|
||||
EducationForm studyForm = calendar.getStudyForm();
|
||||
return new AcademicCalendarDto(
|
||||
calendar.getId(),
|
||||
calendar.getTitle(),
|
||||
year.getId(),
|
||||
year.getTitle(),
|
||||
year.getStartDate(),
|
||||
year.getEndDate(),
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityCode(),
|
||||
speciality.getSpecialityName(),
|
||||
profile.getId(),
|
||||
profile.getName(),
|
||||
studyForm.getId(),
|
||||
studyForm.getName(),
|
||||
calendar.getCourseCount()
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) {
|
||||
AcademicCalendarActivityType activityType = day.getActivityType();
|
||||
return new AcademicCalendarGridDayDto(
|
||||
day.getId(),
|
||||
day.getAcademicCalendar().getId(),
|
||||
day.getCourseNumber(),
|
||||
day.getDate(),
|
||||
day.getWeekNumber(),
|
||||
day.getDayOfWeek(),
|
||||
activityType.getId(),
|
||||
activityType.getCode(),
|
||||
activityType.getName(),
|
||||
activityType.getAllowSchedule()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.JwtProperties;
|
||||
import com.magistr.app.config.auth.JwtTokenService;
|
||||
import com.magistr.app.config.auth.RefreshTokenRotation;
|
||||
import com.magistr.app.config.auth.RefreshTokenService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -18,34 +29,174 @@ public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final JwtProperties jwtProperties;
|
||||
|
||||
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
||||
"ADMIN", "/admin/",
|
||||
"EDUCATION_OFFICE", "/admin/#schedule-view",
|
||||
"DEPARTMENT", "/admin/#department-workspace",
|
||||
"SCHEDULE_VIEWER", "/admin/#schedule-view",
|
||||
"TEACHER", "/teacher/",
|
||||
"STUDENT", "/student/"
|
||||
);
|
||||
|
||||
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
||||
public AuthController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
JwtTokenService jwtTokenService,
|
||||
RefreshTokenService refreshTokenService,
|
||||
JwtProperties jwtProperties) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.jwtProperties = jwtProperties;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||
|
||||
if (userOpt.isEmpty() ||
|
||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
String token = UUID.randomUUID().toString();
|
||||
String roleName = user.getRole().name();
|
||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
||||
if (user.isArchivedRecord()) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||
}
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||
String refreshToken = refreshTokenService.createSession(
|
||||
user,
|
||||
tenant,
|
||||
servletRequest.getHeader("User-Agent"),
|
||||
clientIp(servletRequest)
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
|
||||
.body(loginResponse(user, accessToken));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<LoginResponse> refresh(HttpServletRequest request) {
|
||||
Optional<String> refreshToken = refreshCookieValue(request);
|
||||
if (refreshToken.isEmpty()) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
Optional<RefreshTokenRotation> rotation = refreshTokenService.rotate(
|
||||
refreshToken.get(),
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getHeader("User-Agent"),
|
||||
clientIp(request)
|
||||
);
|
||||
if (rotation.isEmpty()) {
|
||||
return unauthorizedWithClearedCookie();
|
||||
}
|
||||
|
||||
User user = rotation.get().user();
|
||||
String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString())
|
||||
.body(loginResponse(user, accessToken));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
|
||||
refreshCookieValue(request).ifPresent(token ->
|
||||
refreshTokenService.revoke(token, TenantContext.getCurrentTenant())
|
||||
);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
|
||||
.body(Map.of("success", true, "message", "Выход выполнен"));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> me() {
|
||||
var user = AuthContext.getCurrentUser();
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
||||
}
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("userId", user.id());
|
||||
response.put("username", user.username());
|
||||
response.put("role", user.role().name());
|
||||
response.put("departmentId", user.departmentId());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private LoginResponse loginResponse(User user, String token) {
|
||||
String roleName = user.getRole().name();
|
||||
return new LoginResponse(
|
||||
true,
|
||||
"OK",
|
||||
token,
|
||||
roleName,
|
||||
ROLE_REDIRECTS.getOrDefault(roleName, "/"),
|
||||
user.getDepartmentId(),
|
||||
user.getId()
|
||||
);
|
||||
}
|
||||
|
||||
private ResponseEntity<LoginResponse> unauthorized() {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
|
||||
}
|
||||
|
||||
private ResponseEntity<LoginResponse> unauthorizedWithClearedCookie() {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
|
||||
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
|
||||
}
|
||||
|
||||
private ResponseCookie refreshCookie(String token) {
|
||||
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token)
|
||||
.httpOnly(true)
|
||||
.secure(jwtProperties.isRefreshCookieSecure())
|
||||
.sameSite("Lax")
|
||||
.path("/api/auth")
|
||||
.maxAge(jwtProperties.getRefreshTtl())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ResponseCookie clearRefreshCookie() {
|
||||
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "")
|
||||
.httpOnly(true)
|
||||
.secure(jwtProperties.isRefreshCookieSecure())
|
||||
.sameSite("Lax")
|
||||
.path("/api/auth")
|
||||
.maxAge(Duration.ZERO)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Optional<String> refreshCookieValue(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) {
|
||||
return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomRequest;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Equipment;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -15,6 +18,7 @@ import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/classrooms")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ClassroomController {
|
||||
|
||||
private final ClassroomRepository classroomRepository;
|
||||
@@ -26,8 +30,12 @@ public class ClassroomController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ClassroomResponse> getAllClassrooms() {
|
||||
return classroomRepository.findAll().stream()
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
List<Classroom> classrooms = includeArchived
|
||||
? classroomRepository.findAll()
|
||||
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
return classrooms.stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList();
|
||||
}
|
||||
@@ -47,10 +55,14 @@ public class ClassroomController {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setName(request.getName().trim());
|
||||
classroom.setCapacity(request.getCapacity());
|
||||
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||
classroom.setFloor(request.getFloor());
|
||||
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
||||
|
||||
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
|
||||
.filter(Equipment::isActiveRecord)
|
||||
.toList();
|
||||
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||
}
|
||||
|
||||
@@ -80,12 +92,23 @@ public class ClassroomController {
|
||||
classroom.setCapacity(request.getCapacity());
|
||||
}
|
||||
|
||||
if (request.getBuilding() != null) {
|
||||
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||
}
|
||||
|
||||
if (request.getFloor() != null) {
|
||||
classroom.setFloor(request.getFloor());
|
||||
}
|
||||
|
||||
if (request.getIsAvailable() != null) {
|
||||
classroom.setIsAvailable(request.getIsAvailable());
|
||||
}
|
||||
|
||||
if (request.getEquipmentIds() != null) {
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
||||
equipments = equipments.stream()
|
||||
.filter(Equipment::isActiveRecord)
|
||||
.toList();
|
||||
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||
}
|
||||
|
||||
@@ -95,15 +118,37 @@ public class ClassroomController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
|
||||
if (!classroomRepository.existsById(id)) {
|
||||
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
|
||||
if (classroomOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
classroomRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
|
||||
Classroom classroom = classroomOpt.get();
|
||||
classroom.archive("Выведена из эксплуатации");
|
||||
classroom.setIsAvailable(false);
|
||||
classroomRepository.save(classroom);
|
||||
return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreClassroom(@PathVariable Long id) {
|
||||
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
|
||||
if (classroomOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Classroom classroom = classroomOpt.get();
|
||||
classroom.restore();
|
||||
classroom.setIsAvailable(true);
|
||||
classroomRepository.save(classroom);
|
||||
return ResponseEntity.ok(mapToResponse(classroom));
|
||||
}
|
||||
|
||||
private ClassroomResponse mapToResponse(Classroom c) {
|
||||
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
|
||||
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
|
||||
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
|
||||
new java.util.ArrayList<>(c.getEquipments()));
|
||||
}
|
||||
|
||||
private String cleanText(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
184
backend/src/main/java/com/magistr/app/controller/DatabaseController.java
Executable file
184
backend/src/main/java/com/magistr/app/controller/DatabaseController.java
Executable file
@@ -0,0 +1,184 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.config.tenant.ConfigMapUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.model.Role;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* API управления подключениями к базам данных (тенантами).
|
||||
* Доступно только для ADMIN.
|
||||
*
|
||||
* При добавлении/удалении тенанта:
|
||||
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
|
||||
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
|
||||
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/database")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class DatabaseController {
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final ConfigMapUpdater configMapUpdater;
|
||||
private final TenantConfigWatcher configWatcher;
|
||||
|
||||
public DatabaseController(TenantRoutingDataSource routingDataSource,
|
||||
ConfigMapUpdater configMapUpdater,
|
||||
TenantConfigWatcher configWatcher) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.configMapUpdater = configMapUpdater;
|
||||
this.configWatcher = configWatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус текущего подключения (по домену запроса).
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<Map<String, Object>> getStatus() {
|
||||
String currentTenant = TenantContext.getCurrentTenant();
|
||||
boolean connected = routingDataSource.testConnection(currentTenant);
|
||||
|
||||
TenantConfig config = routingDataSource.getTenantConfigs().get(currentTenant);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("tenant", currentTenant);
|
||||
result.put("connected", connected);
|
||||
result.put("configured", config != null);
|
||||
if (config != null) {
|
||||
result.put("name", config.getName());
|
||||
result.put("url", config.getUrl());
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список всех тенантов.
|
||||
*/
|
||||
@GetMapping("/tenants")
|
||||
public ResponseEntity<List<Map<String, Object>>> getTenants() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
|
||||
for (TenantConfig config : routingDataSource.getTenantConfigs().values()) {
|
||||
Map<String, Object> tenant = new HashMap<>();
|
||||
tenant.put("name", config.getName());
|
||||
tenant.put("domain", config.getDomain());
|
||||
tenant.put("url", config.getUrl());
|
||||
tenant.put("username", config.getUsername());
|
||||
tenant.put("connected", routingDataSource.testConnection(config.getDomain()));
|
||||
result.add(tenant);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить новый тенант.
|
||||
*/
|
||||
@PostMapping("/tenants")
|
||||
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (config.getDomain() == null || config.getDomain().isBlank()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Домен не может быть пустым");
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
if (config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "URL базы данных не может быть пустым");
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
if (routingDataSource.hasTenant(config.getDomain())) {
|
||||
routingDataSource.removeTenant(config.getDomain());
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Добавить в in-memory (мгновенно на этом поде)
|
||||
routingDataSource.addTenant(config);
|
||||
|
||||
// 2. Инициализировать БД (init.sql) если нужно
|
||||
configWatcher.initDatabaseForTenant(config);
|
||||
|
||||
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
|
||||
persistToConfigMap();
|
||||
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Ошибка: " + e.getMessage());
|
||||
return ResponseEntity.internalServerError().body(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить тенант.
|
||||
*/
|
||||
@DeleteMapping("/tenants/{domain}")
|
||||
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (!routingDataSource.hasTenant(domain)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Тенант '" + domain + "' не найден");
|
||||
return ResponseEntity.status(404).body(result);
|
||||
}
|
||||
|
||||
routingDataSource.removeTenant(domain);
|
||||
persistToConfigMap();
|
||||
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + domain + "' удалён");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест подключения к произвольной БД.
|
||||
*/
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody Map<String, String> params) {
|
||||
String url = params.get("url");
|
||||
String username = params.get("username");
|
||||
String password = params.get("password");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (url == null || url.isBlank()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "URL не указан");
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
String testResult = routingDataSource.testExternalConnection(url, username, password);
|
||||
boolean success = "OK".equals(testResult);
|
||||
|
||||
result.put("success", success);
|
||||
result.put("message", success ? "Подключение успешно!" : testResult);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет текущий список тенантов в K8s ConfigMap.
|
||||
*/
|
||||
private void persistToConfigMap() {
|
||||
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
|
||||
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
|
||||
if (ok) {
|
||||
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateDepartmentRequest;
|
||||
import com.magistr.app.dto.DepartmentResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/departments")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class DepartmentController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
|
||||
|
||||
private final DepartmentRepository departmentRepository;
|
||||
|
||||
public DepartmentController(DepartmentRepository departmentRepository) {
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка кафедр");
|
||||
try {
|
||||
List<Department> departments = includeArchived
|
||||
? departmentRepository.findAll()
|
||||
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Department> response = departments.stream()
|
||||
.map( d -> new Department(
|
||||
d.getId(),
|
||||
d.getDepartmentName(),
|
||||
d.getDepartmentCode()
|
||||
))
|
||||
.toList();
|
||||
logger.info("Получено {} кафедр", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createDepartment(@RequestBody CreateDepartmentRequest request) {
|
||||
logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode());
|
||||
|
||||
try {
|
||||
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){
|
||||
String errorMessage = "Название кафедры обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) {
|
||||
String errorMessage = "Кафедра с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
|
||||
String errorMessage = "Код кафедры обязателен";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) {
|
||||
String errorMessage = "Кафедра с таким кодом уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
Department department = new Department();
|
||||
department.setDepartmentName(request.getDepartmentName());
|
||||
department.setDepartmentCode(request.getDepartmentCode());
|
||||
departmentRepository.save(department);
|
||||
|
||||
logger.info("Кафедра успешно создана с ID: {}", department.getId());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new DepartmentResponse(
|
||||
department.getId(),
|
||||
department.getDepartmentName(),
|
||||
department.getDepartmentCode()
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateDepartment(@PathVariable Long id, @RequestBody CreateDepartmentRequest request) {
|
||||
logger.info("Получен запрос на обновление кафедры с ID: {}", id);
|
||||
|
||||
Department department = departmentRepository.findById(id).orElse(null);
|
||||
if (department == null) {
|
||||
logger.info("Кафедра с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()) {
|
||||
String errorMessage = "Название кафедры обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
|
||||
String errorMessage = "Код кафедры обязателен";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
String departmentName = request.getDepartmentName().trim();
|
||||
if (departmentRepository.findByDepartmentName(departmentName)
|
||||
.filter(existing -> !existing.getId().equals(id))
|
||||
.isPresent()) {
|
||||
String errorMessage = "Кафедра с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode())
|
||||
.filter(existing -> !existing.getId().equals(id))
|
||||
.isPresent()) {
|
||||
String errorMessage = "Кафедра с таким кодом уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
department.setDepartmentName(departmentName);
|
||||
department.setDepartmentCode(request.getDepartmentCode());
|
||||
departmentRepository.save(department);
|
||||
|
||||
logger.info("Кафедра с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(new DepartmentResponse(
|
||||
department.getId(),
|
||||
department.getDepartmentName(),
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
|
||||
Department department = departmentRepository.findById(id).orElse(null);
|
||||
if (department == null) {
|
||||
logger.info("Кафедра с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
department.archive("Кафедра архивирована");
|
||||
departmentRepository.save(department);
|
||||
logger.info("Кафедра с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Кафедра архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreDepartment(@PathVariable Long id) {
|
||||
Department department = departmentRepository.findById(id).orElse(null);
|
||||
if (department == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
department.restore();
|
||||
departmentRepository.save(department);
|
||||
return ResponseEntity.ok(new DepartmentResponse(
|
||||
department.getId(),
|
||||
department.getDepartmentName(),
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectCommentDto;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.SubjectCommentRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/department")
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
public class DepartmentWorkspaceController {
|
||||
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SubjectCommentRepository subjectCommentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
UserRepository userRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
ScheduleQueryService scheduleQueryService) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.subjectCommentRepository = subjectCommentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
}
|
||||
|
||||
@GetMapping("/subjects")
|
||||
public List<Subject> getSubjects(@RequestParam(required = false) Long departmentId) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
}
|
||||
|
||||
@PostMapping("/subjects/import")
|
||||
public ResponseEntity<?> importSubjects(@RequestBody List<CreateSubjectRequest> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки"));
|
||||
}
|
||||
Long requestedDepartmentId = requests.stream()
|
||||
.map(CreateSubjectRequest::getDepartmentId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
|
||||
if (departmentId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
List<Subject> saved = requests.stream()
|
||||
.filter(request -> request.getName() != null && !request.getName().isBlank())
|
||||
.map(request -> {
|
||||
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
|
||||
subject.setName(request.getName().trim());
|
||||
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
|
||||
subject.setDepartmentId(departmentId);
|
||||
if (subject.isArchivedRecord()) {
|
||||
subject.restore();
|
||||
}
|
||||
return subjectRepository.save(subject);
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Дисциплины загружены",
|
||||
"count", saved.size()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/subjects/{subjectId}/comments")
|
||||
public ResponseEntity<?> getComments(@PathVariable Long subjectId) {
|
||||
Subject subject = subjectRepository.findById(subjectId).orElse(null);
|
||||
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream()
|
||||
.map(this::toCommentDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/subjects/{subjectId}/comments")
|
||||
public ResponseEntity<?> addComment(@PathVariable Long subjectId, @RequestBody Map<String, String> request) {
|
||||
Subject subject = subjectRepository.findById(subjectId).orElse(null);
|
||||
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String commentText = request == null ? null : request.get("comment");
|
||||
if (commentText == null || commentText.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
||||
}
|
||||
SubjectComment comment = new SubjectComment();
|
||||
comment.setSubject(subject);
|
||||
comment.setComment(commentText.trim());
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
if (currentUserId != null) {
|
||||
userRepository.findById(currentUserId).ifPresent(comment::setAuthor);
|
||||
}
|
||||
return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment)));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
if (effectiveDepartmentId == null) {
|
||||
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return teachersById.values().stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/schedule")
|
||||
public ResponseEntity<?> getSchedule(
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
|
||||
) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId,
|
||||
null, null, null, null, startDate, endDate));
|
||||
}
|
||||
|
||||
private SubjectCommentDto toCommentDto(SubjectComment comment) {
|
||||
User author = comment.getAuthor();
|
||||
return new SubjectCommentDto(
|
||||
comment.getId(),
|
||||
comment.getSubject().getId(),
|
||||
author == null ? null : author.getId(),
|
||||
author == null ? null : author.getFullName(),
|
||||
comment.getComment(),
|
||||
comment.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
user.getDepartmentId()
|
||||
);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var user = AuthContext.getCurrentUser();
|
||||
if (user != null && user.role() == Role.DEPARTMENT) {
|
||||
return user.departmentId();
|
||||
}
|
||||
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
|
||||
}
|
||||
|
||||
private boolean canAccessDepartment(Long departmentId) {
|
||||
var user = AuthContext.getCurrentUser();
|
||||
return user == null
|
||||
|| user.role() == Role.ADMIN
|
||||
|| (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -12,20 +15,25 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/education-forms")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class EducationFormController {
|
||||
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
|
||||
public EducationFormController(EducationFormRepository educationFormRepository,
|
||||
GroupRepository groupRepository) {
|
||||
GroupRepository groupRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository) {
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Map<String, Object>> getAll() {
|
||||
return educationFormRepository.findAll().stream()
|
||||
return educationFormRepository.findAllByOrderByNameAsc().stream()
|
||||
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
|
||||
.toList();
|
||||
}
|
||||
@@ -54,12 +62,17 @@ public class EducationFormController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Check if any groups use this education form
|
||||
// Нельзя удалить форму обучения, пока она используется группами или графиками.
|
||||
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
|
||||
if (!linked.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
|
||||
}
|
||||
long linkedCalendars = academicCalendarRepository.countByStudyFormId(id);
|
||||
if (linkedCalendars > 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")"));
|
||||
}
|
||||
|
||||
educationFormRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Equipment;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -10,6 +13,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/equipments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class EquipmentController {
|
||||
|
||||
private final EquipmentRepository equipmentRepository;
|
||||
@@ -19,8 +23,8 @@ public class EquipmentController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Equipment> getAllEquipments() {
|
||||
return equipmentRepository.findAll();
|
||||
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -42,10 +46,23 @@ public class EquipmentController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
|
||||
if (!equipmentRepository.existsById(id)) {
|
||||
Equipment equipment = equipmentRepository.findById(id).orElse(null);
|
||||
if (equipment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
equipmentRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
|
||||
equipment.archive("Оборудование архивировано");
|
||||
equipmentRepository.save(equipment);
|
||||
return ResponseEntity.ok(Map.of("message", "Оборудование архивировано"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreEquipment(@PathVariable Long id) {
|
||||
Equipment equipment = equipmentRepository.findById(id).orElse(null);
|
||||
if (equipment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
equipment.restore();
|
||||
equipmentRepository.save(equipment);
|
||||
return ResponseEntity.ok(equipment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
|
||||
HttpServletRequest request) {
|
||||
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||
? "Некорректный запрос"
|
||||
: exception.getMessage();
|
||||
return error(HttpStatus.BAD_REQUEST, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoSuchElementException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
|
||||
HttpServletRequest request) {
|
||||
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||
? "Запрошенная запись не найдена"
|
||||
: exception.getMessage();
|
||||
return error(HttpStatus.NOT_FOUND, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
HttpMessageNotReadableException.class,
|
||||
MethodArgumentTypeMismatchException.class
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", reason(status));
|
||||
body.put("message", message);
|
||||
body.put("path", request.getRequestURI());
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private String reason(HttpStatus status) {
|
||||
return switch (status) {
|
||||
case BAD_REQUEST -> "Некорректный запрос";
|
||||
case NOT_FOUND -> "Не найдено";
|
||||
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||
default -> status.name();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,374 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/groups")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
|
||||
public class GroupController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||
|
||||
private final GroupRepository groupRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository) {
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<GroupResponse> getAllGroups() {
|
||||
return groupRepository.findAll().stream()
|
||||
.map(g -> new GroupResponse(
|
||||
g.getId(),
|
||||
g.getName(),
|
||||
g.getGroupSize(),
|
||||
g.getEducationForm().getId(),
|
||||
g.getEducationForm().getName()))
|
||||
.toList();
|
||||
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех групп");
|
||||
|
||||
try {
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} групп", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.toList();
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("Группы для указанной кафедры не найдены");
|
||||
}
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList();
|
||||
|
||||
logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Произошла ошибка при получении списка групп");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
|
||||
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
|
||||
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
|
||||
try {
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e ) {
|
||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
||||
}
|
||||
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||
try {
|
||||
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
|
||||
if (groupOpt.isEmpty()) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = groupOpt.get();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление группы с ID - {}", id);
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.archive("Группа архивирована");
|
||||
groupRepository.save(group);
|
||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> restoreGroup(@PathVariable Long id) {
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.restore();
|
||||
groupRepository.save(group);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
}
|
||||
|
||||
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
return validationError("Название группы обязательно");
|
||||
}
|
||||
if (request.getGroupSize() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
|
||||
return validationError("Численность группы обязательна");
|
||||
}
|
||||
if (request.getEducationFormId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
|
||||
return validationError("Форма обучения обязательна");
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
return validationError("ID кафедры обязателен");
|
||||
}
|
||||
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
||||
return validationError("Год начала обучения обязателен");
|
||||
}
|
||||
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
||||
return validationError("Код специальности обязателен");
|
||||
}
|
||||
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
||||
return validationError("Профиль обучения обязателен");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
groupRepository.save(group);
|
||||
private ResponseEntity<?> validationError(String errorMessage) {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(new GroupResponse(
|
||||
private GroupResponse mapToResponse(StudentGroup group) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||
Speciality speciality = group.getSpeciality();
|
||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||
LocalDate today = LocalDate.now();
|
||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||
return new GroupResponse(
|
||||
group.getId(),
|
||||
group.getName(),
|
||||
group.getGroupSize(),
|
||||
group.getEducationForm().getId(),
|
||||
group.getEducationForm().getName()));
|
||||
group.getEducationForm().getName(),
|
||||
group.getDepartmentId(),
|
||||
group.getYearStartStudy(),
|
||||
course,
|
||||
semester,
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityCode(),
|
||||
speciality.getSpecialityName(),
|
||||
profile.getId(),
|
||||
profile.getName(),
|
||||
group.getStatus(),
|
||||
groupLifecycleService.isAvailableForSelection(group, today),
|
||||
studyState.name(),
|
||||
studyState.getLabel()
|
||||
);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||
@GetMapping("/{id}/calendar-assignments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
|
||||
if (!groupRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
||||
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||
.map(this::toAssignmentDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/calendar-assignments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||
@RequestBody GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
|
||||
}
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
|
||||
if (academicYear == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
|
||||
}
|
||||
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
|
||||
}
|
||||
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|
||||
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
|
||||
}
|
||||
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
|
||||
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
assignmentRepository.delete(assignment);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
|
||||
}
|
||||
|
||||
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) {
|
||||
return new GroupCalendarAssignmentDto(
|
||||
assignment.getId(),
|
||||
assignment.getStudentGroup().getId(),
|
||||
assignment.getStudentGroup().getName(),
|
||||
assignment.getAcademicYear().getId(),
|
||||
assignment.getAcademicYear().getTitle(),
|
||||
assignment.getAcademicCalendar().getId(),
|
||||
assignment.getAcademicCalendar().getTitle()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.LessonTypeResponse;
|
||||
import com.magistr.app.model.LessonType;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/lesson-types")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public class LessonTypeController {
|
||||
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
|
||||
public LessonTypeController(LessonTypesRepository lessonTypesRepository) {
|
||||
this.lessonTypesRepository = lessonTypesRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<LessonTypeResponse> getAllLessonTypes() {
|
||||
return lessonTypesRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(LessonType::getLessonType))
|
||||
.map(lessonType -> new LessonTypeResponse(lessonType.getId(), lessonType.getLessonType()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.CreateLessonRequest;
|
||||
import com.magistr.app.dto.LessonResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.utils.DayAndWeekValidator;
|
||||
import com.magistr.app.utils.TypeAndFormatLessonValidator;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/lessons")
|
||||
public class LessonsController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LessonsController.class);
|
||||
|
||||
private final LessonRepository lessonRepository;
|
||||
private final UserRepository teacherRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
|
||||
public LessonsController(LessonRepository lessonRepository, UserRepository teacherRepository, GroupRepository groupRepository, SubjectRepository subjectRepository, EducationFormRepository educationForm, ClassroomRepository classroomRepository) {
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.teacherRepository = teacherRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.educationFormRepository = educationForm;
|
||||
this.classroomRepository = classroomRepository;
|
||||
}
|
||||
|
||||
//Создание нового занятия
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<?> createLesson(@RequestBody CreateLessonRequest request) {
|
||||
//Полное логирование входящего запроса
|
||||
logger.info("Получен запрос на создание занятия: teacherId={}, groupId={}, subjectId={}, day={}, week={}, time={}",
|
||||
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getDay(), request.getWeek(), request.getTime());
|
||||
|
||||
//Проверка teacherId
|
||||
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
|
||||
String errorMessage = "ID преподавателя обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка groupId
|
||||
if (request.getGroupId() == null || request.getGroupId() == 0) {
|
||||
String errorMessage = "ID группы обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка subjectId
|
||||
if (request.getSubjectId() == null || request.getSubjectId() == 0) {
|
||||
String errorMessage = "ID предмета обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка lessonFormat
|
||||
if (request.getLessonFormat() == null || request.getLessonFormat().isBlank()) {
|
||||
String errorMessage = "Выбор формата занятия обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
} else if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())){
|
||||
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
|
||||
logger.info("Ошибка валидации формата: '{}' - {}", request.getLessonFormat(), errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка typeLesson
|
||||
if (request.getTypeLesson() == null || request.getTypeLesson().isBlank()) {
|
||||
String errorMessage = "Выбор типа занятия обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
} else if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())){
|
||||
String errorMessage = "Некорректный тип занятия. " + TypeAndFormatLessonValidator.getValidTypesMessage();
|
||||
logger.info("Ошибка валидации типа: '{}' - {}", request.getTypeLesson(), errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка classroomId
|
||||
if (request.getClassroomId() == null || request.getClassroomId() == 0) {
|
||||
String errorMessage = "ID аудитории обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка day
|
||||
if (request.getDay() == null || request.getDay().isBlank()) {
|
||||
String errorMessage = "Выбор дня обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
} else if(!DayAndWeekValidator.isValidDay(request.getDay())){
|
||||
String errorMessage = "Некорректный день недели. " + DayAndWeekValidator.getValidDaysMessage();
|
||||
logger.info("Ошибка валидации дня: '{}' - {}", request.getDay(), errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка week
|
||||
if (request.getWeek() == null || request.getWeek().isBlank()) {
|
||||
String errorMessage = "Выбор недели обязателен";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
} else if(!DayAndWeekValidator.isValidWeek(request.getWeek())){
|
||||
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
|
||||
logger.info("Ошибка валидации недели: '{}' - {}", request.getWeek(), errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Проверка time
|
||||
if (request.getTime() == null || request.getTime().isBlank()) {
|
||||
String errorMessage = "Время обязательно";
|
||||
logger.info("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
//Сохранение полученных данных и формирование ответа клиенту
|
||||
try {
|
||||
Lesson lesson = new Lesson();
|
||||
lesson.setTeacherId(request.getTeacherId());
|
||||
lesson.setSubjectId(request.getSubjectId());
|
||||
lesson.setGroupId(request.getGroupId());
|
||||
lesson.setLessonFormat(request.getLessonFormat());
|
||||
lesson.setTypeLesson(request.getTypeLesson());
|
||||
lesson.setClassroomId(request.getClassroomId());
|
||||
lesson.setDay(request.getDay());
|
||||
lesson.setWeek(request.getWeek());
|
||||
lesson.setTime(request.getTime());
|
||||
|
||||
Lesson savedLesson = lessonRepository.save(lesson);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("id", savedLesson.getId());
|
||||
response.put("teacherId", savedLesson.getTeacherId());
|
||||
response.put("groupId", savedLesson.getGroupId());
|
||||
response.put("subjectId", savedLesson.getSubjectId());
|
||||
response.put("formatLesson", savedLesson.getLessonFormat());
|
||||
response.put("typeLesson", savedLesson.getTypeLesson());
|
||||
response.put("classroomId", savedLesson.getClassroomId());
|
||||
response.put("day", savedLesson.getDay());
|
||||
response.put("week", savedLesson.getWeek());
|
||||
response.put("time", savedLesson.getTime());
|
||||
|
||||
logger.info("Занятие успешно создано с ID: {}", savedLesson.getId());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при сохранении занятия: {}", e.getMessage(),e);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании занятия: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
//Запрос для получения всего списка занятий
|
||||
@GetMapping
|
||||
public List<LessonResponse> getAllLessons() {
|
||||
logger.info("Запрос на получение всех занятий");
|
||||
|
||||
try {
|
||||
List<Lesson> lessons = lessonRepository.findAll();
|
||||
|
||||
List<LessonResponse> response = lessons.stream()
|
||||
.map(lesson -> {
|
||||
String teacherName = teacherRepository.findById(lesson.getTeacherId())
|
||||
.map(User::getUsername)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
|
||||
String groupName = groupRepository.findById(lesson.getGroupId())
|
||||
.map(StudentGroup::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
String educationFormName = "Неизвестно";
|
||||
if(group != null && group.getEducationForm() != null) {
|
||||
Long educationFormId = group.getEducationForm().getId();
|
||||
educationFormName = educationFormRepository.findById(educationFormId)
|
||||
.map(EducationForm::getName)
|
||||
.orElse("Неизвестно");
|
||||
}
|
||||
|
||||
String subjectName = subjectRepository.findById(lesson.getSubjectId())
|
||||
.map(Subject::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
String classroomName = classroomRepository.findById(lesson.getClassroomId())
|
||||
.map(Classroom::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
return new LessonResponse(
|
||||
lesson.getId(),
|
||||
teacherName,
|
||||
groupName,
|
||||
classroomName,
|
||||
educationFormName,
|
||||
subjectName,
|
||||
lesson.getTypeLesson(),
|
||||
lesson.getLessonFormat(),
|
||||
lesson.getDay(),
|
||||
lesson.getWeek(),
|
||||
lesson.getTime()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
logger.info("Получено {} занятий", lessons.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка всех занятий: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
//Запрос на получение всех занятий для конкретного преподавателя
|
||||
@GetMapping("/{teacherId}")
|
||||
public ResponseEntity<?> getLessonsById(@PathVariable Long teacherId) {
|
||||
logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId);
|
||||
try {
|
||||
List<Lesson> lessons = lessonRepository.findByTeacherId(teacherId);
|
||||
|
||||
if(lessons.isEmpty()) {
|
||||
logger.info("У преподавателя с ID {} нет занятий", teacherId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "У преподавателя с ID " + teacherId +" нет занятий.",
|
||||
"lessons", Collections.emptyList()
|
||||
));
|
||||
}
|
||||
|
||||
List<LessonResponse> lessonResponses = lessons.stream()
|
||||
.map(lesson -> {
|
||||
String teacherName = teacherRepository.findById(lesson.getTeacherId())
|
||||
.map(User::getUsername)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
|
||||
String groupName = groupRepository.findById(lesson.getGroupId())
|
||||
.map(StudentGroup::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
String educationFormName = "Неизвестно";
|
||||
if(group != null && group.getEducationForm() != null) {
|
||||
Long educationFormId = group.getEducationForm().getId();
|
||||
educationFormName = educationFormRepository.findById(educationFormId)
|
||||
.map(EducationForm::getName)
|
||||
.orElse("Неизвестно");
|
||||
}
|
||||
|
||||
String subjectName = subjectRepository.findById(lesson.getSubjectId())
|
||||
.map(Subject::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
String classroomName = classroomRepository.findById(lesson.getClassroomId())
|
||||
.map(Classroom::getName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
return new LessonResponse(
|
||||
lesson.getId(),
|
||||
teacherName,
|
||||
groupName,
|
||||
classroomName,
|
||||
educationFormName,
|
||||
subjectName,
|
||||
lesson.getTypeLesson(),
|
||||
lesson.getLessonFormat(),
|
||||
lesson.getDay(),
|
||||
lesson.getWeek(),
|
||||
lesson.getTime()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
logger.info("Найдено {} занятий для преподавателя с ID: {}", lessonResponses.size(), teacherId);
|
||||
return ResponseEntity.ok(lessonResponses);
|
||||
} catch (Exception e ){
|
||||
logger.error("Ошибка при получении занятий для преподавателя с ID {}: {}", teacherId, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Ошибка при поиске занятий: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
//Тестовый запрос на проверку доступности контроллера
|
||||
@GetMapping("/ping")
|
||||
public String ping() {
|
||||
logger.debug("Получен ping запрос");
|
||||
String response = "pong";
|
||||
logger.debug("Ответ на ping: {}", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
//Удаление занятия по его ID
|
||||
@DeleteMapping("/delete/{lessonId}")
|
||||
public ResponseEntity<?> deleteLessonById(@PathVariable Long lessonId){
|
||||
logger.info("Запрос на удаление занятия по ID: {}", lessonId);
|
||||
if(!lessonRepository.existsById(lessonId)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Занятие не найдено"));
|
||||
}
|
||||
lessonRepository.deleteById(lessonId);
|
||||
logger.info("Занятие с ID - {} успешно удалено", lessonId);
|
||||
return ResponseEntity.ok(Map.of("message", "Занятие успешно удалено"));
|
||||
|
||||
}
|
||||
|
||||
//Обновление занятия по его ID
|
||||
@PutMapping("/update/{lessonId}")
|
||||
public ResponseEntity<?> updateLessonById(@PathVariable Long lessonId, @RequestBody CreateLessonRequest request) {
|
||||
logger.info("Получен запрос на обновление занятия с ID - {}", lessonId);
|
||||
logger.info("Данные для обновления: teacherId={}, groupId={}, subjectId={}, lessonFormat={}, typeLesson={}, classroomId={}, day={}, week={}, time={}",
|
||||
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getLessonFormat(), request.getTypeLesson(), request.getClassroomId(),
|
||||
request.getDay(), request.getWeek(), request.getTime());
|
||||
|
||||
try {
|
||||
//Проверка на наличие записи
|
||||
Lesson existingLesson = lessonRepository.findById(lessonId).orElse(null);
|
||||
|
||||
if(existingLesson == null) {
|
||||
String errorMessage = "Занятие с ID " + lessonId + " не найдено";
|
||||
logger.info("Ошибка: {}", errorMessage);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
boolean hasChanges = false;
|
||||
Map<String, Object> changes = new LinkedHashMap<>();
|
||||
|
||||
//Проверка и обновление teacherId, если он передан и отличается
|
||||
if(request.getTeacherId() != null) {
|
||||
if(!request.getTeacherId().equals(existingLesson.getTeacherId())) {
|
||||
if(request.getTeacherId() == 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "ID преподавателя не может быть равен 0"));
|
||||
}
|
||||
existingLesson.setTeacherId(request.getTeacherId());
|
||||
changes.put("teacherId", request.getTeacherId());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление groupId, если он передан и отличается
|
||||
if(request.getGroupId() != null) {
|
||||
if(!request.getGroupId().equals(existingLesson.getGroupId())) {
|
||||
if(request.getGroupId() == 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "ID группы не может быть равен 0"));
|
||||
}
|
||||
existingLesson.setGroupId(request.getGroupId());
|
||||
changes.put("groupId", request.getGroupId());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление subjectId, если он передан и отличается
|
||||
if(request.getSubjectId() != null) {
|
||||
if(!request.getSubjectId().equals(existingLesson.getSubjectId())) {
|
||||
if(request.getSubjectId() == 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "ID дисциплины не может быть равен 0"));
|
||||
}
|
||||
existingLesson.setSubjectId(request.getSubjectId());
|
||||
changes.put("subjectId", request.getSubjectId());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление lessonFormat, если он передан и отличается
|
||||
if(request.getLessonFormat() != null) {
|
||||
if(!request.getLessonFormat().equals(existingLesson.getLessonFormat())) {
|
||||
if(request.getLessonFormat().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Формат занятия не может быть пустым"));
|
||||
}
|
||||
if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())) {
|
||||
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
existingLesson.setLessonFormat(request.getLessonFormat());
|
||||
changes.put("lessonFormat", request.getLessonFormat());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление typeLesson, если он передан и отличается
|
||||
if(request.getTypeLesson() != null) {
|
||||
if(!request.getTypeLesson().equals(existingLesson.getTypeLesson())) {
|
||||
if(request.getTypeLesson().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Тип занятия не может быть пустым"));
|
||||
}
|
||||
if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())) {
|
||||
String errorMessage = "Некорректный тип занятий. " + TypeAndFormatLessonValidator.getValidTypesMessage();
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
existingLesson.setLessonFormat(request.getTypeLesson());
|
||||
changes.put("typeLesson", request.getTypeLesson());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление classroomId, если он передан и отличается
|
||||
if(request.getClassroomId() != null) {
|
||||
if(!request.getClassroomId().equals(existingLesson.getClassroomId())) {
|
||||
if(request.getClassroomId() == 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "ID аудитории не можеть быть равен 0"));
|
||||
}
|
||||
existingLesson.setClassroomId(request.getClassroomId());
|
||||
changes.put("classroomId", request.getClassroomId());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление day, если он передан и отличается
|
||||
if(request.getDay() != null){
|
||||
if(!request.getDay().equals(existingLesson.getDay())) {
|
||||
if(request.getDay().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Поле \"День\" не может быть пустым"));
|
||||
}
|
||||
if(!DayAndWeekValidator.isValidDay(request.getDay())) {
|
||||
String errorMessage = "Некорректный день. " + DayAndWeekValidator.getValidDaysMessage();
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", errorMessage));
|
||||
}
|
||||
existingLesson.setDay(request.getDay());
|
||||
changes.put("day", request.getDay());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление week, если он передан и отличается
|
||||
if(request.getWeek() != null) {
|
||||
if(!request.getWeek().equals(existingLesson.getWeek())) {
|
||||
if (request.getWeek().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Поле \"Неделя\" не может быть пустым"));
|
||||
}
|
||||
if (!DayAndWeekValidator.isValidWeek(request.getWeek())) {
|
||||
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
|
||||
return ResponseEntity.badRequest()
|
||||
.body((Map.of("message", errorMessage)));
|
||||
}
|
||||
existingLesson.setWeek(request.getWeek());
|
||||
changes.put("week", request.getWeek());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка и обновление time, если он передан и отличается
|
||||
if(request.getTime() != null) {
|
||||
if(!request.getTime().equals(existingLesson.getTime())) {
|
||||
if(request.getTime().isBlank()){
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Поле \"Время\" не может быть пустым"));
|
||||
}
|
||||
existingLesson.setTime(request.getTime());
|
||||
changes.put("time", request.getTime());
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(!hasChanges) {
|
||||
logger.info("Обновление не требуется - все полня идентичны существующим для занятия с ID: {}", lessonId);
|
||||
|
||||
Map<String, Object> response = buildResponse(existingLesson);
|
||||
response.put("message", "Изменений не обнаружено");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
Lesson updatedLesson = lessonRepository.save(existingLesson);
|
||||
|
||||
Map<String, Object> response = buildResponse(updatedLesson);
|
||||
response.put("updatedFields", changes);
|
||||
response.put("message", "Занятие успешно обновлено");
|
||||
|
||||
logger.info("Занятие с ID - {} успешно обновлено. Изменения: {}", lessonId, changes);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении занятия с ID {}: {}", lessonId, e.getMessage(),e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении занятия: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildResponse(Lesson lesson) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("id", lesson.getId());
|
||||
response.put("teacherId", lesson.getTeacherId());
|
||||
response.put("groupId", lesson.getGroupId());
|
||||
response.put("subjectId", lesson.getSubjectId());
|
||||
response.put("LessonFormat", lesson.getLessonFormat());
|
||||
response.put("typeLesson", lesson.getTypeLesson());
|
||||
response.put("classroomId", lesson.getClassroomId());
|
||||
response.put("day", lesson.getDay());
|
||||
response.put("week", lesson.getWeek());
|
||||
response.put("time", lesson.getTime());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/schedule")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
|
||||
public class ScheduleController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getSchedule(
|
||||
@RequestParam(required = false) Long groupId,
|
||||
@RequestParam(required = false) Long teacherId,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
|
||||
) {
|
||||
logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}",
|
||||
groupId, teacherId, startDate, endDate);
|
||||
|
||||
if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(scheduleQueryService.search(
|
||||
groupId,
|
||||
teacherId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
startDate,
|
||||
endDate
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/edu-office/schedule/overrides")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleOverrideController {
|
||||
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleOverrideDto> getAll() {
|
||||
return scheduleOverrideRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
|
||||
return save(null, request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
|
||||
if (!scheduleOverrideRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return save(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!scheduleOverrideRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
scheduleOverrideRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
|
||||
}
|
||||
|
||||
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
ScheduleOverride override = id == null
|
||||
? scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
|
||||
.orElseGet(ScheduleOverride::new)
|
||||
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
|
||||
|
||||
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
|
||||
override.setLessonDate(request.lessonDate());
|
||||
override.setAction(request.action());
|
||||
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
|
||||
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
|
||||
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
|
||||
override.setNewLessonFormat(clean(request.newLessonFormat()));
|
||||
override.setComment(clean(request.comment()));
|
||||
override.setCreatedBy(AuthContext.currentUserId());
|
||||
|
||||
ScheduleOverride saved = scheduleOverrideRepository.save(override);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
private String validate(ScheduleOverrideDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте данные изменения расписания";
|
||||
}
|
||||
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
|
||||
return "Базовый слот расписания не найден";
|
||||
}
|
||||
if (request.lessonDate() == null) {
|
||||
return "Дата пары обязательна";
|
||||
}
|
||||
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
|
||||
return "Недопустимое действие изменения расписания";
|
||||
}
|
||||
if (request.newClassroomId() != null) {
|
||||
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
|
||||
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
|
||||
return "Активная аудитория не найдена";
|
||||
}
|
||||
}
|
||||
if (request.newTeacherId() != null) {
|
||||
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return "Активный преподаватель не найден";
|
||||
}
|
||||
}
|
||||
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
|
||||
return "Временной слот не найден";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto toDto(ScheduleOverride override) {
|
||||
return new ScheduleOverrideDto(
|
||||
override.getId(),
|
||||
override.getBaseRuleSlot().getId(),
|
||||
override.getLessonDate(),
|
||||
override.getAction(),
|
||||
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
|
||||
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
|
||||
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
|
||||
override.getNewLessonFormat(),
|
||||
override.getComment(),
|
||||
override.getCreatedBy(),
|
||||
override.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/schedule-rules")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
GroupRepository groupRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
UserRepository userRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
LessonTypesRepository lessonTypesRepository,
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.lessonTypesRepository = lessonTypesRepository;
|
||||
this.subgroupRepository = subgroupRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
|
||||
@RequestParam(required = false) Long groupId) {
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getById(@PathVariable Long id) {
|
||||
return scheduleRuleRepository.findByIdWithDetails(id)
|
||||
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
try {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
try {
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
|
||||
}
|
||||
|
||||
private String validateRule(ScheduleRuleDto request) {
|
||||
if (request.subjectId() == null) {
|
||||
return "Дисциплина обязательна";
|
||||
}
|
||||
if (request.semesterId() == null) {
|
||||
return "Семестр обязателен";
|
||||
}
|
||||
String hoursValidationError = validateTypeHours(request);
|
||||
if (hoursValidationError != null) {
|
||||
return hoursValidationError;
|
||||
}
|
||||
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
||||
return "Нужно выбрать хотя бы одну группу";
|
||||
}
|
||||
if (request.slots() == null || request.slots().isEmpty()) {
|
||||
return "Добавьте хотя бы один слот занятия";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
if (subject.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
|
||||
}
|
||||
Semester semester = semesterRepository.findById(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
|
||||
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
|
||||
throw new IllegalArgumentException("Одна или несколько групп не найдены");
|
||||
}
|
||||
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
|
||||
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
|
||||
}
|
||||
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setLectureAcademicHours(request.lectureAcademicHours());
|
||||
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||
rule.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||
rule.setLectureStartWeek(request.lectureStartWeek());
|
||||
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
|
||||
rule.setPracticeStartWeek(request.practiceStartWeek());
|
||||
rule.getGroups().clear();
|
||||
rule.getGroups().addAll(groups);
|
||||
|
||||
List<ScheduleRuleSlot> slots = new ArrayList<>();
|
||||
for (ScheduleRuleSlotDto slotDto : request.slots()) {
|
||||
slots.add(buildSlot(rule, slotDto));
|
||||
}
|
||||
validateTypeCoverage(rule, slots);
|
||||
|
||||
rule.getSlots().clear();
|
||||
rule.getSlots().addAll(slots);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
||||
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
|
||||
}
|
||||
if (slotDto.parity() == null) {
|
||||
throw new IllegalArgumentException("Чётность недели обязательна");
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
if (timeSlot.getTimeSlotScope() == null
|
||||
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
|
||||
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
|
||||
}
|
||||
User teacher = userRepository.findById(slotDto.teacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
if (teacher.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
|
||||
}
|
||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
|
||||
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
|
||||
}
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
|
||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setScheduleRule(rule);
|
||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||
slot.setParity(slotDto.parity());
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setSubgroups(subgroups);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
slot.setLessonFormat(slotDto.lessonFormat().trim());
|
||||
return slot;
|
||||
}
|
||||
|
||||
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
|
||||
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
|
||||
if (slotDto.subgroupIds() != null) {
|
||||
slotDto.subgroupIds().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(subgroupIds::add);
|
||||
}
|
||||
if (slotDto.subgroupId() != null) {
|
||||
subgroupIds.add(slotDto.subgroupId());
|
||||
}
|
||||
if (subgroupIds.isEmpty()) {
|
||||
return new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
if (category != ScheduleLessonCategory.LABORATORY) {
|
||||
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
|
||||
}
|
||||
|
||||
Set<Long> ruleGroupIds = rule.getGroups().stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<Long> subgroupGroupIds = new HashSet<>();
|
||||
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
|
||||
|
||||
for (Long subgroupId : subgroupIds) {
|
||||
Subgroup subgroup = subgroupRepository.findById(subgroupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
|
||||
Long subgroupGroupId = subgroup.getStudentGroup().getId();
|
||||
if (!ruleGroupIds.contains(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
|
||||
}
|
||||
if (!subgroupGroupIds.add(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
|
||||
}
|
||||
subgroups.add(subgroup);
|
||||
}
|
||||
|
||||
return subgroups;
|
||||
}
|
||||
|
||||
private String validateTypeHours(ScheduleRuleDto request) {
|
||||
if (isMissing(request.lectureAcademicHours())
|
||||
|| isMissing(request.laboratoryAcademicHours())
|
||||
|| isMissing(request.practiceAcademicHours())) {
|
||||
return "Укажите количество часов для всех типов занятий";
|
||||
}
|
||||
if (isNegative(request.lectureAcademicHours())
|
||||
|| isNegative(request.laboratoryAcademicHours())
|
||||
|| isNegative(request.practiceAcademicHours())) {
|
||||
return "Количество часов по типам занятий не может быть отрицательным";
|
||||
}
|
||||
if (isInvalidStartWeek(request.lectureStartWeek())
|
||||
|| isInvalidStartWeek(request.laboratoryStartWeek())
|
||||
|| isInvalidStartWeek(request.practiceStartWeek())) {
|
||||
return "Неделя начала занятий должна быть больше нуля";
|
||||
}
|
||||
int totalHours = valueOrZero(request.lectureAcademicHours())
|
||||
+ valueOrZero(request.laboratoryAcademicHours())
|
||||
+ valueOrZero(request.practiceAcademicHours());
|
||||
if (totalHours <= 0) {
|
||||
return "Укажите часы хотя бы для одного типа занятий";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
|
||||
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
|
||||
}
|
||||
|
||||
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
|
||||
int hours = rule.academicHoursFor(category);
|
||||
boolean hasSlots = categoriesWithSlots.contains(category);
|
||||
if (hours > 0 && !hasSlots) {
|
||||
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
|
||||
}
|
||||
if (hours == 0 && hasSlots) {
|
||||
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMissing(Integer value) {
|
||||
return value == null;
|
||||
}
|
||||
|
||||
private boolean isNegative(Integer value) {
|
||||
return value < 0;
|
||||
}
|
||||
|
||||
private boolean isInvalidStartWeek(Integer value) {
|
||||
return value == null || value <= 0;
|
||||
}
|
||||
|
||||
private int valueOrZero(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||
List<StudentGroup> groups = rule.getGroups().stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
|
||||
.sorted(Comparator
|
||||
.comparing(ScheduleRuleSlot::getDayOfWeek)
|
||||
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
|
||||
.thenComparing(ScheduleRuleSlot::getId))
|
||||
.map(this::toSlotDto)
|
||||
.toList();
|
||||
|
||||
return new ScheduleRuleDto(
|
||||
rule.getId(),
|
||||
rule.getSubject().getId(),
|
||||
rule.getSubject().getName(),
|
||||
rule.getSemester().getId(),
|
||||
rule.getSemester().getAcademicYear().getTitle(),
|
||||
rule.getSemester().getSemesterType(),
|
||||
rule.getLectureAcademicHours(),
|
||||
rule.getLaboratoryAcademicHours(),
|
||||
rule.getPracticeAcademicHours(),
|
||||
rule.getLectureStartWeek(),
|
||||
rule.getLaboratoryStartWeek(),
|
||||
rule.getPracticeStartWeek(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||
return new ScheduleRuleSlotDto(
|
||||
slot.getId(),
|
||||
slot.getDayOfWeek(),
|
||||
dayName(slot.getDayOfWeek()),
|
||||
slot.getParity(),
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
formatTimeSlot(timeSlot),
|
||||
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
|
||||
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
|
||||
sortedSubgroups.stream().map(Subgroup::getId).toList(),
|
||||
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
|
||||
slot.getTeacher().getId(),
|
||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
slot.getClassroom().getName(),
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat()
|
||||
);
|
||||
}
|
||||
|
||||
private String dayName(Integer dayOfWeek) {
|
||||
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
|
||||
return "Неизвестно";
|
||||
}
|
||||
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
|
||||
}
|
||||
|
||||
private String formatTimeSlot(TimeSlot timeSlot) {
|
||||
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
|
||||
}
|
||||
|
||||
private String formatSubgroup(Subgroup subgroup) {
|
||||
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||
}
|
||||
|
||||
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
|
||||
return slot.getSubgroups().stream()
|
||||
.sorted(Comparator
|
||||
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||
.thenComparing(Subgroup::getName))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/schedule/search")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
|
||||
public class ScheduleSearchController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class);
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
|
||||
public ScheduleSearchController(ScheduleQueryService scheduleQueryService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> search(
|
||||
@RequestParam(required = false) Long groupId,
|
||||
@RequestParam(required = false) Long teacherId,
|
||||
@RequestParam(required = false) Long classroomId,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Long subjectId,
|
||||
@RequestParam(required = false) Long lessonTypeId,
|
||||
@RequestParam(required = false) Long timeSlotId,
|
||||
@RequestParam(required = false) String parity,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
|
||||
) {
|
||||
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
|
||||
groupId, teacherId, classroomId, departmentId, startDate, endDate);
|
||||
return ResponseEntity.ok(scheduleQueryService.search(
|
||||
groupId,
|
||||
teacherId,
|
||||
classroomId,
|
||||
departmentId,
|
||||
subjectId,
|
||||
lessonTypeId,
|
||||
timeSlotId,
|
||||
parity,
|
||||
startDate,
|
||||
endDate
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSpecialityRequest;
|
||||
import com.magistr.app.dto.SpecialityResponse;
|
||||
import com.magistr.app.dto.SpecialtyProfileDto;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/specialties")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class SpecialityController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
|
||||
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
|
||||
public SpecialityController(SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository) {
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка специальностей");
|
||||
try {
|
||||
List<Speciality> specialities = includeArchived
|
||||
? specialtiesRepository.findAll()
|
||||
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Speciality> response = specialities.stream()
|
||||
.map( s -> new Speciality(
|
||||
s.getId(),
|
||||
s.getSpecialityName(),
|
||||
s.getSpecialityCode()
|
||||
))
|
||||
.toList();
|
||||
logger.info("Получено {} специальностей", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createSpeciality(@RequestBody CreateSpecialityRequest request) {
|
||||
logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode());
|
||||
|
||||
try {
|
||||
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
|
||||
String errorMessage = "Название специальности обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) {
|
||||
String errorMessage = "Специальность с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
|
||||
String errorMessage = "Код специальности обязателен";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) {
|
||||
String errorMessage = "Специальность с таким кодом уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
Speciality speciality = new Speciality();
|
||||
speciality.setSpecialityName(request.getSpecialityName());
|
||||
speciality.setSpecialityCode(request.getSpecialityCode());
|
||||
specialtiesRepository.save(speciality);
|
||||
SpecialtyProfile defaultProfile = new SpecialtyProfile();
|
||||
defaultProfile.setSpeciality(speciality);
|
||||
defaultProfile.setName("Без профиля");
|
||||
specialtyProfileRepository.save(defaultProfile);
|
||||
|
||||
logger.info("Специальность успешно создана с ID: {}", speciality.getId());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new SpecialityResponse(
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityName(),
|
||||
speciality.getSpecialityCode()
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateSpeciality(@PathVariable Long id, @RequestBody CreateSpecialityRequest request) {
|
||||
logger.info("Получен запрос на обновление специальности с ID: {}", id);
|
||||
|
||||
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
|
||||
if (speciality == null) {
|
||||
logger.info("Специальность с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
|
||||
String errorMessage = "Название специальности обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
|
||||
String errorMessage = "Код специальности обязателен";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
String specialityName = request.getSpecialityName().trim();
|
||||
String specialityCode = request.getSpecialityCode().trim();
|
||||
if (specialtiesRepository.findBySpecialityName(specialityName)
|
||||
.filter(existing -> !existing.getId().equals(id))
|
||||
.isPresent()) {
|
||||
String errorMessage = "Специальность с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (specialtiesRepository.findBySpecialityCode(specialityCode)
|
||||
.filter(existing -> !existing.getId().equals(id))
|
||||
.isPresent()) {
|
||||
String errorMessage = "Специальность с таким кодом уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
speciality.setSpecialityName(specialityName);
|
||||
speciality.setSpecialityCode(specialityCode);
|
||||
specialtiesRepository.save(speciality);
|
||||
|
||||
logger.info("Специальность с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(new SpecialityResponse(
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityName(),
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление специальности с ID: {}", id);
|
||||
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
|
||||
if (speciality == null) {
|
||||
logger.info("Специальность с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
speciality.archive("Специальность архивирована");
|
||||
specialtiesRepository.save(speciality);
|
||||
logger.info("Специальность с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Специальность архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreSpeciality(@PathVariable Long id) {
|
||||
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
speciality.restore();
|
||||
specialtiesRepository.save(speciality);
|
||||
return ResponseEntity.ok(new SpecialityResponse(
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityName(),
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/profiles")
|
||||
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||
logger.info("Получен запрос на получение всех профилей обучения");
|
||||
return specialtyProfileRepository.findAll().stream()
|
||||
.filter(profile -> profile.getSpeciality().isActiveRecord())
|
||||
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
|
||||
.map(this::toProfileDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{specialtyId}/profiles")
|
||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream()
|
||||
.map(this::toProfileDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{specialtyId}/profiles")
|
||||
public ResponseEntity<?> createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) {
|
||||
Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateProfile(specialtyId, null, request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
SpecialtyProfile profile = new SpecialtyProfile();
|
||||
profile.setSpeciality(speciality);
|
||||
profile.setName(request.name().trim());
|
||||
profile.setDescription(trimToNull(request.description()));
|
||||
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
|
||||
}
|
||||
|
||||
@PutMapping("/{specialtyId}/profiles/{profileId}")
|
||||
public ResponseEntity<?> updateProfile(@PathVariable Long specialtyId,
|
||||
@PathVariable Long profileId,
|
||||
@RequestBody SpecialtyProfileDto request) {
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateProfile(specialtyId, profileId, request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
profile.setName(request.name().trim());
|
||||
profile.setDescription(trimToNull(request.description()));
|
||||
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{specialtyId}/profiles/{profileId}")
|
||||
public ResponseEntity<?> deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) {
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте данные профиля";
|
||||
}
|
||||
if (request.name() == null || request.name().isBlank()) {
|
||||
return "Название профиля обязательно";
|
||||
}
|
||||
return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim())
|
||||
.filter(existing -> !existing.getId().equals(profileId))
|
||||
.map(existing -> "Профиль с таким названием уже существует")
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) {
|
||||
Speciality speciality = profile.getSpeciality();
|
||||
return new SpecialtyProfileDto(
|
||||
profile.getId(),
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityCode(),
|
||||
speciality.getSpecialityName(),
|
||||
profile.getName(),
|
||||
profile.getDescription()
|
||||
);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.SubgroupDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
||||
public class SubgroupController {
|
||||
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public SubgroupController(SubgroupRepository subgroupRepository,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.subgroupRepository = subgroupRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/subgroups")
|
||||
public List<SubgroupDto> getAll() {
|
||||
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/api/groups/{groupId}/subgroups")
|
||||
public ResponseEntity<?> getByGroup(@PathVariable Long groupId) {
|
||||
if (!groupRepository.existsById(groupId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream()
|
||||
.map(this::toDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/api/groups/{groupId}/subgroups")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
|
||||
StudentGroup group = groupRepository.findById(groupId).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
||||
}
|
||||
|
||||
Subgroup subgroup = new Subgroup();
|
||||
subgroup.setStudentGroup(group);
|
||||
subgroup.setName(request.name().trim());
|
||||
subgroup.setStudentCapacity(request.studentCapacity());
|
||||
Subgroup saved = subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> update(@PathVariable Long groupId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody SubgroupDto request) {
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
String newName = request.name().trim();
|
||||
boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName)
|
||||
&& !subgroup.getName().equalsIgnoreCase(newName);
|
||||
if (duplicateName) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
||||
}
|
||||
|
||||
subgroup.setName(newName);
|
||||
subgroup.setStudentCapacity(request.studentCapacity());
|
||||
Subgroup saved = subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
try {
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(SubgroupDto request) {
|
||||
if (request == null || request.name() == null || request.name().isBlank()) {
|
||||
return "Название подгруппы обязательно";
|
||||
}
|
||||
if (request.studentCapacity() != null && request.studentCapacity() < 0) {
|
||||
return "Численность подгруппы не может быть отрицательной";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SubgroupDto toDto(Subgroup subgroup) {
|
||||
StudentGroup group = subgroup.getStudentGroup();
|
||||
return new SubgroupDto(
|
||||
subgroup.getId(),
|
||||
group.getId(),
|
||||
group.getName(),
|
||||
subgroup.getName(),
|
||||
subgroup.getStudentCapacity()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -10,8 +19,11 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/subjects")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class SubjectController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
|
||||
|
||||
private final SubjectRepository subjectRepository;
|
||||
|
||||
public SubjectController(SubjectRepository subjectRepository) {
|
||||
@@ -19,33 +31,129 @@ public class SubjectController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Subject> getAllSubjects() {
|
||||
return subjectRepository.findAll();
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех дисциплин");
|
||||
try {
|
||||
List<Subject> subjects = includeArchived
|
||||
? subjectRepository.findAll()
|
||||
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Subject> response = subjects.stream()
|
||||
.map(s -> new Subject(
|
||||
s.getId(),
|
||||
s.getName(),
|
||||
s.getCode(),
|
||||
s.getDepartmentId()
|
||||
))
|
||||
.toList();
|
||||
logger.info("Получено {} дисциплин", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
|
||||
try{
|
||||
List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
if(subjects.isEmpty()){
|
||||
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("Дисциплины для указанной кафедры не найдены");
|
||||
}
|
||||
|
||||
logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId);
|
||||
return ResponseEntity.ok(subjects);
|
||||
} catch (Exception e) {
|
||||
logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Произошла ошибка при получении списка дисциплин");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
|
||||
String name = request.get("name");
|
||||
if (name == null || name.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
|
||||
}
|
||||
if (subjectRepository.findByName(name.trim()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
|
||||
}
|
||||
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
|
||||
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
|
||||
request.getName(), request.getCode(), request.getDepartmentId());
|
||||
|
||||
Subject subject = new Subject();
|
||||
subject.setName(name.trim());
|
||||
subjectRepository.save(subject);
|
||||
try {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
String errorMessage = "Название обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getCode() == null || request.getCode().isBlank()) {
|
||||
String errorMessage = "Код дисциплины обязателен";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(subject);
|
||||
Subject subject = new Subject();
|
||||
subject.setName(request.getName());
|
||||
subject.setCode(request.getCode());
|
||||
subject.setDepartmentId(request.getDepartmentId());
|
||||
subjectRepository.save(subject);
|
||||
|
||||
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new SubjectResponse(
|
||||
subject.getId(),
|
||||
subject.getName(),
|
||||
subject.getCode(),
|
||||
subject.getDepartmentId()
|
||||
)
|
||||
);
|
||||
} catch (Exception e){
|
||||
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
||||
if (!subjectRepository.existsById(id)) {
|
||||
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
|
||||
Subject subject = subjectRepository.findById(id).orElse(null);
|
||||
if (subject == null) {
|
||||
logger.info("Дисциплина с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
subjectRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
||||
subject.archive("Дисциплина архивирована");
|
||||
subjectRepository.save(subject);
|
||||
logger.info("Дисциплина с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> restoreSubject(@PathVariable Long id) {
|
||||
Subject subject = subjectRepository.findById(id).orElse(null);
|
||||
if (subject == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
subject.restore();
|
||||
subjectRepository.save(subject);
|
||||
return ResponseEntity.ok(new SubjectResponse(
|
||||
subject.getId(),
|
||||
subject.getName(),
|
||||
subject.getCode(),
|
||||
subject.getDepartmentId()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.TeacherSubjectResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.TeacherSubject;
|
||||
import com.magistr.app.model.TeacherSubjectId;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
@@ -14,6 +19,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/teacher-subjects")
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
public class TeacherSubjectController {
|
||||
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
|
||||
@GetMapping
|
||||
public List<TeacherSubjectResponse> getAll() {
|
||||
return teacherSubjectRepository.findAll().stream()
|
||||
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||
.map(ts -> new TeacherSubjectResponse(
|
||||
ts.getUserId(),
|
||||
ts.getUser().getUsername(),
|
||||
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
|
||||
if (userId == null || subjectId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
|
||||
}
|
||||
if (!userRepository.existsById(userId)) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (!subjectRepository.existsById(subjectId)) {
|
||||
Subject subject = subjectRepository.findById(subjectId).orElse(null);
|
||||
if (subject == null || subject.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
|
||||
}
|
||||
if (!canManage(user, subject)) {
|
||||
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины"));
|
||||
}
|
||||
|
||||
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||
if (teacherSubjectRepository.existsById(id)) {
|
||||
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
|
||||
}
|
||||
|
||||
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||
if (!teacherSubjectRepository.existsById(id)) {
|
||||
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
|
||||
if (teacherSubject == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
|
||||
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
|
||||
}
|
||||
teacherSubjectRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
|
||||
}
|
||||
|
||||
private boolean canManage(User teacher, Subject subject) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser == null || currentUser.role() == Role.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
if (currentUser.role() != Role.DEPARTMENT) {
|
||||
return false;
|
||||
}
|
||||
Long departmentId = currentUser.departmentId();
|
||||
return departmentId != null
|
||||
&& teacher != null
|
||||
&& subject != null
|
||||
&& departmentId.equals(teacher.getDepartmentId())
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.dto.TimeSlotScopeDto;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/time-slots")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class TimeSlotAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||
private static final String APPLY_MODE_MANUAL = "MANUAL";
|
||||
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TimeSlotDto> getAll() {
|
||||
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/effective")
|
||||
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
|
||||
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
public List<TimeSlotScopeDto> getScopes() {
|
||||
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
|
||||
.map(this::toScopeDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/scopes")
|
||||
public ResponseEntity<?> createScope(@RequestBody TimeSlotScopeDto request) {
|
||||
if (request.name() == null || request.name().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
|
||||
}
|
||||
|
||||
TimeSlotScope scope = new TimeSlotScope();
|
||||
scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", ""));
|
||||
scope.setName(request.name().trim());
|
||||
scope.setApplyMode(APPLY_MODE_MANUAL);
|
||||
scope.setDayOfWeek(null);
|
||||
scope.setSystemScope(false);
|
||||
scope.setDisplayOrder(nextManualDisplayOrder());
|
||||
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
|
||||
}
|
||||
|
||||
@PutMapping("/scopes/{id}")
|
||||
public ResponseEntity<?> updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) {
|
||||
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
|
||||
if (scope == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (Boolean.TRUE.equals(scope.getSystemScope())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять"));
|
||||
}
|
||||
if (request.name() == null || request.name().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
|
||||
}
|
||||
scope.setName(request.name().trim());
|
||||
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/scopes/{id}")
|
||||
public ResponseEntity<?> deleteScope(@PathVariable Long id) {
|
||||
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
|
||||
if (scope == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (Boolean.TRUE.equals(scope.getSystemScope())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить"));
|
||||
}
|
||||
timeSlotScopeRepository.delete(scope);
|
||||
return ResponseEntity.ok(Map.of("message", "Сетка времени удалена"));
|
||||
}
|
||||
|
||||
@GetMapping("/date-assignments")
|
||||
public List<TimeSlotDateAssignmentDto> getDateAssignments(@RequestParam(required = false) LocalDate startDate,
|
||||
@RequestParam(required = false) LocalDate endDate) {
|
||||
List<TimeSlotDateAssignment> assignments = startDate != null && endDate != null
|
||||
? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate)
|
||||
: dateAssignmentRepository.findAllByOrderByDateAsc();
|
||||
return assignments.stream().map(this::toAssignmentDto).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/date-assignments")
|
||||
public ResponseEntity<?> saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) {
|
||||
if (request.date() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна"));
|
||||
}
|
||||
if (request.scopeId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна"));
|
||||
}
|
||||
TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null);
|
||||
if (scope == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена"));
|
||||
}
|
||||
if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки"));
|
||||
}
|
||||
|
||||
TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date())
|
||||
.orElseGet(TimeSlotDateAssignment::new);
|
||||
assignment.setDate(request.date());
|
||||
assignment.setTimeSlotScope(scope);
|
||||
return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/date-assignments/{id}")
|
||||
public ResponseEntity<?> deleteDateAssignment(@PathVariable Long id) {
|
||||
if (!dateAssignmentRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
dateAssignmentRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено"));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, null)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
||||
if (timeSlot == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||
return "Выберите сетку времени";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
return "Время начала должно быть раньше времени окончания";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
? request.durationMinutes()
|
||||
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
|
||||
timeSlot.setDurationMinutes(duration);
|
||||
}
|
||||
|
||||
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
|
||||
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
|
||||
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||
TimeSlotScope defaultScope = defaultScope();
|
||||
TimeSlotScope effectiveScope = effectiveScope(date);
|
||||
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||
if (effectiveScope.getId().equals(defaultScope.getId())) {
|
||||
return defaultSlots;
|
||||
}
|
||||
|
||||
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
return new ArrayList<>(slotsByOrder.values());
|
||||
}
|
||||
|
||||
private TimeSlotScope effectiveScope(LocalDate date) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()))
|
||||
.orElseGet(this::defaultScope);
|
||||
}
|
||||
|
||||
private TimeSlotScope defaultScope() {
|
||||
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||
}
|
||||
|
||||
private int nextManualDisplayOrder() {
|
||||
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
|
||||
.map(TimeSlotScope::getDisplayOrder)
|
||||
.filter(value -> value != null)
|
||||
.max(Integer::compareTo)
|
||||
.orElse(100) + 10;
|
||||
}
|
||||
|
||||
private TimeSlotDto toDto(TimeSlot timeSlot) {
|
||||
TimeSlotScope scope = timeSlot.getTimeSlotScope();
|
||||
return new TimeSlotDto(
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
scope == null ? null : scope.getId(),
|
||||
scope == null ? null : scope.getName(),
|
||||
scope == null ? null : scope.getApplyMode(),
|
||||
scope == null ? null : scope.getDayOfWeek(),
|
||||
timeSlot.getStartTime(),
|
||||
timeSlot.getEndTime(),
|
||||
timeSlot.getDurationMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) {
|
||||
return new TimeSlotScopeDto(
|
||||
scope.getId(),
|
||||
scope.getCode(),
|
||||
scope.getName(),
|
||||
scope.getApplyMode(),
|
||||
scope.getDayOfWeek(),
|
||||
scope.getSystemScope(),
|
||||
scope.getDisplayOrder()
|
||||
);
|
||||
}
|
||||
|
||||
private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) {
|
||||
TimeSlotScope scope = assignment.getTimeSlotScope();
|
||||
return new TimeSlotDateAssignmentDto(
|
||||
assignment.getId(),
|
||||
assignment.getDate(),
|
||||
scope.getId(),
|
||||
scope.getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,138 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateUserRequest;
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class UserController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<UserResponse> getAllUsers() {
|
||||
return userRepository.findAll().stream()
|
||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
||||
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех пользователей");
|
||||
List<User> users = includeArchived
|
||||
? userRepository.findAll()
|
||||
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<UserResponse> response = users.stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} пользователей", response.size());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> getTeachers() {
|
||||
return userRepository.findByRole(Role.TEACHER).stream()
|
||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
||||
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} преподавателей", response.size());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||
Role.TEACHER,
|
||||
departmentId,
|
||||
LifecycleEntity.STATUS_ARCHIVED
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("Преподаватели для указанной кафедры не найдены");
|
||||
}
|
||||
|
||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||
return ResponseEntity.ok(users.stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||
|
||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
|
||||
String errorMessage = "Имя пользователя обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
|
||||
String errorMessage = "Пароль минимум 4 символа";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
|
||||
String errorMessage = "Пользователь уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
||||
String errorMessage = "Имя пользователя обязательно";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
||||
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
||||
request.setJobTitle("Не указано");
|
||||
}
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
Role role;
|
||||
try {
|
||||
role = Role.valueOf(request.getRole());
|
||||
} catch (Exception e) {
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||
}
|
||||
|
||||
@@ -61,17 +140,153 @@ public class UserController {
|
||||
user.setUsername(request.getUsername());
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
user.setRole(role);
|
||||
user.setFullName(request.getFullName());
|
||||
user.setJobTitle(request.getJobTitle());
|
||||
user.setDepartmentId(request.getDepartmentId());
|
||||
userRepository.save(user);
|
||||
if (role == Role.TEACHER) {
|
||||
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
||||
if (department != null) {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
|
||||
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||
return ResponseEntity.ok(toUserResponse(user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
|
||||
User user = userRepository.findById(id).orElse(null);
|
||||
if (user == null) {
|
||||
logger.info("Пользователь с ID - {} не найден", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.archive("Пользователь архивирован");
|
||||
userRepository.save(user);
|
||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreUser(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElse(null);
|
||||
if (user == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.restore();
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok(toUserResponse(user));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/department-history")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
|
||||
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
userRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
|
||||
.map(this::toAssignmentDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/department-transfer")
|
||||
@Transactional
|
||||
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
||||
User teacher = userRepository.findById(id).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
|
||||
}
|
||||
if (request.departmentId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
|
||||
|
||||
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
|
||||
.ifPresent(current -> {
|
||||
if (current.getValidFrom().isBefore(validFrom)) {
|
||||
current.setValidTo(validFrom.minusDays(1));
|
||||
teacherDepartmentAssignmentRepository.save(current);
|
||||
} else {
|
||||
teacherDepartmentAssignmentRepository.delete(current);
|
||||
}
|
||||
});
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(request.comment());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
teacher.setDepartmentId(department.getId());
|
||||
userRepository.save(teacher);
|
||||
|
||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/by-department/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
|
||||
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
|
||||
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(User::isActiveRecord)
|
||||
.map(this::toUserResponse)
|
||||
.toList());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
String departmentName = user.getDepartmentId() == null
|
||||
? null
|
||||
: departmentRepository.findById(user.getDepartmentId())
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
departmentName
|
||||
);
|
||||
response.setDepartmentId(user.getDepartmentId());
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
||||
return new TeacherDepartmentAssignmentDto(
|
||||
assignment.getId(),
|
||||
assignment.getTeacher().getId(),
|
||||
assignment.getTeacher().getFullName(),
|
||||
assignment.getDepartment().getId(),
|
||||
assignment.getDepartment().getDepartmentName(),
|
||||
assignment.getValidFrom(),
|
||||
assignment.getValidTo(),
|
||||
assignment.getPrimaryAssignment(),
|
||||
assignment.getComment()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.WorkloadSummaryDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/workload")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public class WorkloadController {
|
||||
|
||||
private static final int ACADEMIC_HOURS_PER_LESSON = 2;
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
|
||||
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
public List<WorkloadSummaryDto> teacherWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
|
||||
teacherId -> {
|
||||
User teacher = teachersById.get(teacherId);
|
||||
return teacher == null ? null : teacher.getDepartmentId();
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
public List<WorkloadSummaryDto> classroomWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
|
||||
}
|
||||
|
||||
@GetMapping("/departments")
|
||||
public List<WorkloadSummaryDto> departmentWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Map<Long, String> departments = departmentRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
|
||||
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
||||
.collect(Collectors.groupingBy(lesson -> {
|
||||
User teacher = teachersById.get(lesson.teacherId());
|
||||
return teacher == null ? 0L : teacher.getDepartmentId();
|
||||
}));
|
||||
|
||||
return byDepartment.entrySet().stream()
|
||||
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
||||
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/time-slots")
|
||||
public List<WorkloadSummaryDto> timeSlotWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
||||
lesson -> lesson.timeSlotOrder() == null
|
||||
? "Пара"
|
||||
: lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")",
|
||||
ignored -> null);
|
||||
}
|
||||
|
||||
@GetMapping("/free-classrooms")
|
||||
public List<ClassroomResponse> freeClassrooms(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam Long timeSlotId
|
||||
) {
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
|
||||
null, null, timeSlotId, null, date, date)
|
||||
.stream()
|
||||
.map(RenderedLessonDto::classroomId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
|
||||
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
|
||||
.map(this::toClassroomResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<WorkloadSummaryDto> summarize(List<RenderedLessonDto> lessons,
|
||||
Function<RenderedLessonDto, Long> idFn,
|
||||
Function<RenderedLessonDto, String> nameFn,
|
||||
Function<Long, Long> departmentFn) {
|
||||
Map<Long, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> idFn.apply(lesson) != null)
|
||||
.collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList()));
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue()))
|
||||
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List<RenderedLessonDto> lessons) {
|
||||
return new WorkloadSummaryDto(
|
||||
id,
|
||||
name,
|
||||
departmentId,
|
||||
departmentName(departmentId),
|
||||
lessons.size(),
|
||||
lessons.size() * ACADEMIC_HOURS_PER_LESSON,
|
||||
lessons.stream()
|
||||
.map(lesson -> lesson.date() + ":" + lesson.timeSlotId())
|
||||
.distinct()
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
private String departmentName(Long departmentId) {
|
||||
if (departmentId == null) {
|
||||
return null;
|
||||
}
|
||||
return departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Кафедра не найдена");
|
||||
}
|
||||
|
||||
private ClassroomResponse toClassroomResponse(Classroom classroom) {
|
||||
return new ClassroomResponse(
|
||||
classroom.getId(),
|
||||
classroom.getName(),
|
||||
classroom.getCapacity(),
|
||||
classroom.getBuilding(),
|
||||
classroom.getFloor(),
|
||||
classroom.getIsAvailable(),
|
||||
classroom.getStatus(),
|
||||
classroom.getArchivedAt(),
|
||||
classroom.getArchiveReason(),
|
||||
new ArrayList<>(classroom.getEquipments())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record AcademicCalendarActivityTypeDto(
|
||||
Long id,
|
||||
String code,
|
||||
String name,
|
||||
Boolean allowSchedule,
|
||||
String colorCode,
|
||||
Integer displayOrder,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record AcademicCalendarDto(
|
||||
Long id,
|
||||
String title,
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
LocalDate academicYearStartDate,
|
||||
LocalDate academicYearEndDate,
|
||||
Long specialtyId,
|
||||
String specialtyCode,
|
||||
String specialtyName,
|
||||
Long specialtyProfileId,
|
||||
String specialtyProfileName,
|
||||
Long studyFormId,
|
||||
String studyFormName,
|
||||
Integer courseCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record AcademicCalendarGridDayDto(
|
||||
Long id,
|
||||
Long calendarId,
|
||||
Integer courseNumber,
|
||||
LocalDate date,
|
||||
Integer weekNumber,
|
||||
Integer dayOfWeek,
|
||||
Long activityTypeId,
|
||||
String activityCode,
|
||||
String activityName,
|
||||
Boolean allowSchedule
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record AcademicYearDto(
|
||||
Long id,
|
||||
String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
List<SemesterDto> semesters
|
||||
) {
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import java.util.List;
|
||||
public class ClassroomRequest {
|
||||
private String name;
|
||||
private Integer capacity;
|
||||
private String building;
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private List<Long> equipmentIds;
|
||||
|
||||
@@ -24,6 +26,22 @@ public class ClassroomRequest {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public String getBuilding() {
|
||||
return building;
|
||||
}
|
||||
|
||||
public void setBuilding(String building) {
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
public Integer getFloor() {
|
||||
return floor;
|
||||
}
|
||||
|
||||
public void setFloor(Integer floor) {
|
||||
this.floor = floor;
|
||||
}
|
||||
|
||||
public Boolean getIsAvailable() {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.Equipment;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public class ClassroomResponse {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer capacity;
|
||||
private String building;
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private String status;
|
||||
private LocalDateTime archivedAt;
|
||||
private String archiveReason;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
public ClassroomResponse() {
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, List<Equipment> equipments) {
|
||||
this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments);
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, String status, LocalDateTime archivedAt,
|
||||
String archiveReason, List<Equipment> equipments) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.capacity = capacity;
|
||||
this.building = building;
|
||||
this.floor = floor;
|
||||
this.isAvailable = isAvailable;
|
||||
this.status = status;
|
||||
this.archivedAt = archivedAt;
|
||||
this.archiveReason = archiveReason;
|
||||
this.equipments = equipments;
|
||||
}
|
||||
|
||||
@@ -45,6 +63,22 @@ public class ClassroomResponse {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public String getBuilding() {
|
||||
return building;
|
||||
}
|
||||
|
||||
public void setBuilding(String building) {
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
public Integer getFloor() {
|
||||
return floor;
|
||||
}
|
||||
|
||||
public void setFloor(Integer floor) {
|
||||
this.floor = floor;
|
||||
}
|
||||
|
||||
public Boolean getIsAvailable() {
|
||||
return isAvailable;
|
||||
}
|
||||
@@ -53,6 +87,30 @@ public class ClassroomResponse {
|
||||
this.isAvailable = isAvailable;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
public String getArchiveReason() {
|
||||
return archiveReason;
|
||||
}
|
||||
|
||||
public void setArchiveReason(String archiveReason) {
|
||||
this.archiveReason = archiveReason;
|
||||
}
|
||||
|
||||
public List<Equipment> getEquipments() {
|
||||
return equipments;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ public class CreateGroupRequest {
|
||||
private String name;
|
||||
private Long groupSize;
|
||||
private Long educationFormId;
|
||||
private Long departmentId;
|
||||
private Integer yearStartStudy;
|
||||
private Long specialtyId;
|
||||
private Long specialtyProfileId;
|
||||
private Long specialityCode;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
@@ -29,4 +34,48 @@ public class CreateGroupRequest {
|
||||
public void setEducationFormId(Long educationFormId) {
|
||||
this.educationFormId = educationFormId;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Integer getYearStartStudy() {
|
||||
return yearStartStudy;
|
||||
}
|
||||
|
||||
public void setYearStartStudy(Integer yearStartStudy) {
|
||||
this.yearStartStudy = yearStartStudy;
|
||||
}
|
||||
|
||||
public Long getSpecialityCode() {
|
||||
return specialityCode;
|
||||
}
|
||||
|
||||
public void setSpecialityCode(Long specialityCode) {
|
||||
this.specialityCode = specialityCode;
|
||||
}
|
||||
|
||||
public Long getSpecialtyId() {
|
||||
return specialtyId;
|
||||
}
|
||||
|
||||
public void setSpecialtyId(Long specialtyId) {
|
||||
this.specialtyId = specialtyId;
|
||||
}
|
||||
|
||||
public Long getSpecialtyProfileId() {
|
||||
return specialtyProfileId;
|
||||
}
|
||||
|
||||
public void setSpecialtyProfileId(Long specialtyProfileId) {
|
||||
this.specialtyProfileId = specialtyProfileId;
|
||||
}
|
||||
|
||||
public Long getEffectiveSpecialtyId() {
|
||||
return specialtyId != null ? specialtyId : specialityCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public class CreateLessonRequest {
|
||||
|
||||
private Long teacherId;
|
||||
private Long groupId;
|
||||
private Long subjectId;
|
||||
private String lessonFormat;
|
||||
private String typeLesson;
|
||||
private Long classroomId;
|
||||
private String day;
|
||||
private String week;
|
||||
private String time;
|
||||
|
||||
public CreateLessonRequest() {
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
return teacherId;
|
||||
}
|
||||
|
||||
public void setTeacherId(Long teacherId) {
|
||||
this.teacherId = teacherId;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(Long groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public Long getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
public void setSubjectId(Long subjectId) {
|
||||
this.subjectId = subjectId;
|
||||
}
|
||||
|
||||
public String getLessonFormat() {
|
||||
return lessonFormat;
|
||||
}
|
||||
|
||||
public void setLessonFormat(String lessonFormat) {
|
||||
this.lessonFormat = lessonFormat;
|
||||
}
|
||||
|
||||
public String getTypeLesson() {
|
||||
return typeLesson;
|
||||
}
|
||||
|
||||
public void setTypeLesson(String typeLesson) {
|
||||
this.typeLesson = typeLesson;
|
||||
}
|
||||
|
||||
public Long getClassroomId() {
|
||||
return classroomId;
|
||||
}
|
||||
|
||||
public void setClassroomId(Long classroomId) {
|
||||
this.classroomId = classroomId;
|
||||
}
|
||||
|
||||
public String getDay() {
|
||||
return day;
|
||||
}
|
||||
|
||||
public void setDay(String day) {
|
||||
this.day = day;
|
||||
}
|
||||
|
||||
public String getWeek() {
|
||||
return week;
|
||||
}
|
||||
|
||||
public void setWeek(String week) {
|
||||
this.week = week;
|
||||
}
|
||||
|
||||
public String getTime() {
|
||||
return time;
|
||||
}
|
||||
|
||||
public void setTime(String time) {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ public class CreateUserRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String role;
|
||||
private String fullName;
|
||||
private String jobTitle;
|
||||
private Long departmentId;
|
||||
|
||||
public CreateUserRequest() {
|
||||
}
|
||||
@@ -32,4 +35,28 @@ public class CreateUserRequest {
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getJobTitle() {
|
||||
return jobTitle;
|
||||
}
|
||||
|
||||
public void setJobTitle(String jobTitle) {
|
||||
this.jobTitle = jobTitle;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DepartmentTransferRequest(
|
||||
Long departmentId,
|
||||
LocalDate validFrom,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record GroupCalendarAssignmentDto(
|
||||
Long id,
|
||||
Long groupId,
|
||||
String groupName,
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
Long calendarId,
|
||||
String calendarTitle
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class GroupResponse {
|
||||
|
||||
private Long id;
|
||||
@@ -7,13 +10,56 @@ public class GroupResponse {
|
||||
private Long groupSize;
|
||||
private Long educationFormId;
|
||||
private String educationFormName;
|
||||
private Long departmentId;
|
||||
private Integer yearStartStudy;
|
||||
private Integer course;
|
||||
private Integer semester;
|
||||
private Long specialtyId;
|
||||
private String specialtyCode;
|
||||
private String specialtyName;
|
||||
private Long specialtyProfileId;
|
||||
private String specialtyProfileName;
|
||||
private String status;
|
||||
private Boolean active;
|
||||
private String studyState;
|
||||
private String studyStateName;
|
||||
|
||||
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName) {
|
||||
public GroupResponse(Long id,
|
||||
String name,
|
||||
Long groupSize,
|
||||
Long educationFormId,
|
||||
String educationFormName,
|
||||
Long departmentId,
|
||||
Integer yearStartStudy,
|
||||
Integer course,
|
||||
Integer semester,
|
||||
Long specialtyId,
|
||||
String specialtyCode,
|
||||
String specialtyName,
|
||||
Long specialtyProfileId,
|
||||
String specialtyProfileName,
|
||||
String status,
|
||||
Boolean active,
|
||||
String studyState,
|
||||
String studyStateName) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.groupSize = groupSize;
|
||||
this.educationFormId = educationFormId;
|
||||
this.educationFormName = educationFormName;
|
||||
this.departmentId = departmentId;
|
||||
this.yearStartStudy = yearStartStudy;
|
||||
this.course = course;
|
||||
this.semester = semester;
|
||||
this.specialtyId = specialtyId;
|
||||
this.specialtyCode = specialtyCode;
|
||||
this.specialtyName = specialtyName;
|
||||
this.specialtyProfileId = specialtyProfileId;
|
||||
this.specialtyProfileName = specialtyProfileName;
|
||||
this.status = status;
|
||||
this.active = active;
|
||||
this.studyState = studyState;
|
||||
this.studyStateName = studyStateName;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -35,4 +81,60 @@ public class GroupResponse {
|
||||
public String getEducationFormName() {
|
||||
return educationFormName;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public Integer getCourse() {
|
||||
return course;
|
||||
}
|
||||
|
||||
public Integer getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public Integer getYearStartStudy() {
|
||||
return yearStartStudy;
|
||||
}
|
||||
|
||||
public Long getSpecialityCode() {
|
||||
return specialtyId;
|
||||
}
|
||||
|
||||
public Long getSpecialtyId() {
|
||||
return specialtyId;
|
||||
}
|
||||
|
||||
public String getSpecialtyCode() {
|
||||
return specialtyCode;
|
||||
}
|
||||
|
||||
public String getSpecialtyName() {
|
||||
return specialtyName;
|
||||
}
|
||||
|
||||
public Long getSpecialtyProfileId() {
|
||||
return specialtyProfileId;
|
||||
}
|
||||
|
||||
public String getSpecialtyProfileName() {
|
||||
return specialtyProfileName;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Boolean getActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public String getStudyState() {
|
||||
return studyState;
|
||||
}
|
||||
|
||||
public String getStudyStateName() {
|
||||
return studyStateName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class LessonResponse {
|
||||
|
||||
private Long id;
|
||||
private Long teacherId;
|
||||
private String teacherName;
|
||||
private Long groupId;
|
||||
private String groupName;
|
||||
private String educationFormName;
|
||||
private Long subjectId;
|
||||
private String subjectName;
|
||||
private String lessonFormat;
|
||||
private String typeLesson;
|
||||
private Long classroomId;
|
||||
private String classroomName;
|
||||
private String day;
|
||||
private String week;
|
||||
private String time;
|
||||
|
||||
public LessonResponse() {
|
||||
}
|
||||
|
||||
public LessonResponse(Long id, Long teacherId, Long groupId, Long subjectId, String day, String week, String time) {
|
||||
this.id = id;
|
||||
this.teacherId = teacherId;
|
||||
this.groupId = groupId;
|
||||
this.subjectId = subjectId;
|
||||
this.day = day;
|
||||
this.week = week;
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
public LessonResponse(Long id, String teacherName, String groupName, String classroomName, String educationFormName, String subjectName, String typeLesson, String lessonFormat, String day, String week, String time) {
|
||||
this.id = id;
|
||||
this.teacherName = teacherName;
|
||||
this.groupName = groupName;
|
||||
this.classroomName = classroomName;
|
||||
this.educationFormName = educationFormName;
|
||||
this.subjectName = subjectName;
|
||||
this.typeLesson = typeLesson;
|
||||
this.lessonFormat = lessonFormat;
|
||||
this.day = day;
|
||||
this.week = week;
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
return teacherId;
|
||||
}
|
||||
|
||||
public void setTeacherId(Long teacherId) {
|
||||
this.teacherId = teacherId;
|
||||
}
|
||||
|
||||
public String getTeacherName() {
|
||||
return teacherName;
|
||||
}
|
||||
|
||||
public void setTeacherName(String teacherName) {
|
||||
this.teacherName = teacherName;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(Long groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public String getGroupName() {
|
||||
return groupName;
|
||||
}
|
||||
|
||||
public void setGroupName(String groupName) {
|
||||
this.groupName = groupName;
|
||||
}
|
||||
|
||||
public String getTypeLesson() {
|
||||
return typeLesson;
|
||||
}
|
||||
|
||||
public void setTypeLesson(String typeLesson) {
|
||||
this.typeLesson = typeLesson;
|
||||
}
|
||||
|
||||
public String getLessonFormat() {
|
||||
return lessonFormat;
|
||||
}
|
||||
|
||||
public void setLessonFormat(String lessonFormat) {
|
||||
this.lessonFormat = lessonFormat;
|
||||
}
|
||||
|
||||
public Long getClassroomId() {
|
||||
return classroomId;
|
||||
}
|
||||
|
||||
public void setClassroomId(Long classroomId) {
|
||||
this.classroomId = classroomId;
|
||||
}
|
||||
|
||||
public String getClassroomName() {
|
||||
return classroomName;
|
||||
}
|
||||
|
||||
public void setClassroomName(String classroomName) {
|
||||
this.classroomName = classroomName;
|
||||
}
|
||||
|
||||
public String getEducationFormName() {
|
||||
return educationFormName;
|
||||
}
|
||||
|
||||
public void setEducationFormName(String educationFormName) {
|
||||
this.educationFormName = educationFormName;
|
||||
}
|
||||
|
||||
public Long getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
public void setSubjectId(Long subjectId) {
|
||||
this.subjectId = subjectId;
|
||||
}
|
||||
|
||||
public String getSubjectName() {
|
||||
return subjectName;
|
||||
}
|
||||
|
||||
public void setSubjectName(String subjectName) {
|
||||
this.subjectName = subjectName;
|
||||
}
|
||||
|
||||
public String getDay() {
|
||||
return day;
|
||||
}
|
||||
|
||||
public void setDay(String day) {
|
||||
this.day = day;
|
||||
}
|
||||
|
||||
public String getWeek() {
|
||||
return week;
|
||||
}
|
||||
|
||||
public void setWeek(String week) {
|
||||
this.week = week;
|
||||
}
|
||||
|
||||
public String getTime() {
|
||||
return time;
|
||||
}
|
||||
|
||||
public void setTime(String time) {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record LessonTypeResponse(
|
||||
Long id,
|
||||
String name
|
||||
) {
|
||||
}
|
||||
@@ -7,16 +7,24 @@ public class LoginResponse {
|
||||
private String token;
|
||||
private String role;
|
||||
private String redirect;
|
||||
private Long departmentId;
|
||||
private Long userId;
|
||||
|
||||
public LoginResponse() {
|
||||
}
|
||||
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect) {
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
|
||||
this(success, message, token, role, redirect, departmentId, null);
|
||||
}
|
||||
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.token = token;
|
||||
this.role = role;
|
||||
this.redirect = redirect;
|
||||
this.departmentId = departmentId;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
@@ -58,4 +66,20 @@ public class LoginResponse {
|
||||
public void setRedirect(String redirect) {
|
||||
this.redirect = redirect;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
public record RenderedLessonDto(
|
||||
Long scheduleRuleId,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate date,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
Integer weekNumber,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRuleDto(
|
||||
Long id,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long semesterId,
|
||||
String academicYearTitle,
|
||||
SemesterType semesterType,
|
||||
Integer lectureAcademicHours,
|
||||
Integer laboratoryAcademicHours,
|
||||
Integer practiceAcademicHours,
|
||||
Integer lectureStartWeek,
|
||||
Integer laboratoryStartWeek,
|
||||
Integer practiceStartWeek,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
List<ScheduleRuleSlotDto> slots
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRuleSlotDto(
|
||||
Long id,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
String timeSlotLabel,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat
|
||||
) {
|
||||
}
|
||||
15
backend/src/main/java/com/magistr/app/dto/SemesterDto.java
Normal file
15
backend/src/main/java/com/magistr/app/dto/SemesterDto.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record SemesterDto(
|
||||
Long id,
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
SemesterType semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record SpecialtyProfileDto(
|
||||
Long id,
|
||||
Long specialtyId,
|
||||
String specialtyCode,
|
||||
String specialtyName,
|
||||
String name,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
10
backend/src/main/java/com/magistr/app/dto/SubgroupDto.java
Normal file
10
backend/src/main/java/com/magistr/app/dto/SubgroupDto.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record SubgroupDto(
|
||||
Long id,
|
||||
Long groupId,
|
||||
String groupName,
|
||||
String name,
|
||||
Integer studentCapacity
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record SubjectCommentDto(
|
||||
Long id,
|
||||
Long subjectId,
|
||||
Long authorId,
|
||||
String authorName,
|
||||
String comment,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TeacherDepartmentAssignmentDto(
|
||||
Long id,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long departmentId,
|
||||
String departmentName,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo,
|
||||
Boolean primaryAssignment,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TimeSlotDateAssignmentDto(
|
||||
Long id,
|
||||
LocalDate date,
|
||||
Long scopeId,
|
||||
String scopeName
|
||||
) {
|
||||
}
|
||||
16
backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java
Normal file
16
backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
public record TimeSlotDto(
|
||||
Long id,
|
||||
Integer orderNumber,
|
||||
Long scopeId,
|
||||
String scopeName,
|
||||
String scopeApplyMode,
|
||||
Integer scopeDayOfWeek,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Integer durationMinutes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TimeSlotScopeDto(
|
||||
Long id,
|
||||
String code,
|
||||
String name,
|
||||
String applyMode,
|
||||
Integer dayOfWeek,
|
||||
Boolean systemScope,
|
||||
Integer displayOrder
|
||||
) {
|
||||
}
|
||||
@@ -1,41 +1,85 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class UserResponse {
|
||||
|
||||
private Long id;
|
||||
private String username;
|
||||
private String role;
|
||||
private String fullName;
|
||||
private String jobTitle;
|
||||
private String departmentName;
|
||||
private Long departmentId;
|
||||
private String status;
|
||||
|
||||
public UserResponse() {
|
||||
}
|
||||
|
||||
public UserResponse(Long id, String username, String role) {
|
||||
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.role = role;
|
||||
this.fullName = fullName;
|
||||
this.jobTitle = jobTitle;
|
||||
this.departmentName = departmentName;
|
||||
}
|
||||
|
||||
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.role = role;
|
||||
this.fullName = fullName;
|
||||
this.jobTitle = jobTitle;
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) {
|
||||
this.id = id;
|
||||
this.role = role;
|
||||
this.fullName = fullName;
|
||||
this.jobTitle = jobTitle;
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public String getJobTitle() {
|
||||
return jobTitle;
|
||||
}
|
||||
|
||||
public String getDepartmentName() {
|
||||
return departmentName;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record WorkloadSummaryDto(
|
||||
Long id,
|
||||
String name,
|
||||
Long departmentId,
|
||||
String departmentName,
|
||||
long lessonCount,
|
||||
int academicHours,
|
||||
long occupiedSlotCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_calendars")
|
||||
public class AcademicCalendar {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "academic_year_id", nullable = false)
|
||||
private AcademicYear academicYear;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "specialty_id", nullable = false)
|
||||
private Speciality speciality;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "specialty_profile_id", nullable = false)
|
||||
private SpecialtyProfile specialtyProfile;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "study_form_id", nullable = false)
|
||||
private EducationForm studyForm;
|
||||
|
||||
@Column(name = "course_count", nullable = false)
|
||||
private Integer courseCount;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public AcademicYear getAcademicYear() {
|
||||
return academicYear;
|
||||
}
|
||||
|
||||
public void setAcademicYear(AcademicYear academicYear) {
|
||||
this.academicYear = academicYear;
|
||||
}
|
||||
|
||||
public Speciality getSpeciality() {
|
||||
return speciality;
|
||||
}
|
||||
|
||||
public void setSpeciality(Speciality speciality) {
|
||||
this.speciality = speciality;
|
||||
}
|
||||
|
||||
public SpecialtyProfile getSpecialtyProfile() {
|
||||
return specialtyProfile;
|
||||
}
|
||||
|
||||
public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) {
|
||||
this.specialtyProfile = specialtyProfile;
|
||||
}
|
||||
|
||||
public EducationForm getStudyForm() {
|
||||
return studyForm;
|
||||
}
|
||||
|
||||
public void setStudyForm(EducationForm studyForm) {
|
||||
this.studyForm = studyForm;
|
||||
}
|
||||
|
||||
public Integer getCourseCount() {
|
||||
return courseCount;
|
||||
}
|
||||
|
||||
public void setCourseCount(Integer courseCount) {
|
||||
this.courseCount = courseCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_calendar_activity_types")
|
||||
public class AcademicCalendarActivityType {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 10)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "allow_schedule", nullable = false)
|
||||
private Boolean allowSchedule;
|
||||
|
||||
@Column(name = "color_code", nullable = false, length = 7)
|
||||
private String colorCode;
|
||||
|
||||
@Column(name = "display_order", nullable = false)
|
||||
private Integer displayOrder;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Boolean getAllowSchedule() {
|
||||
return allowSchedule;
|
||||
}
|
||||
|
||||
public void setAllowSchedule(Boolean allowSchedule) {
|
||||
this.allowSchedule = allowSchedule;
|
||||
}
|
||||
|
||||
public String getColorCode() {
|
||||
return colorCode;
|
||||
}
|
||||
|
||||
public void setColorCode(String colorCode) {
|
||||
this.colorCode = colorCode;
|
||||
}
|
||||
|
||||
public Integer getDisplayOrder() {
|
||||
return displayOrder;
|
||||
}
|
||||
|
||||
public void setDisplayOrder(Integer displayOrder) {
|
||||
this.displayOrder = displayOrder;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_calendar_days")
|
||||
public class AcademicCalendarDay {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "calendar_id", nullable = false)
|
||||
private AcademicCalendar academicCalendar;
|
||||
|
||||
@Column(name = "course_number", nullable = false)
|
||||
private Integer courseNumber;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
@Column(name = "week_number", nullable = false)
|
||||
private Integer weekNumber;
|
||||
|
||||
@Column(name = "day_of_week", nullable = false)
|
||||
private Integer dayOfWeek;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "activity_type_id", nullable = false)
|
||||
private AcademicCalendarActivityType activityType;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public AcademicCalendar getAcademicCalendar() {
|
||||
return academicCalendar;
|
||||
}
|
||||
|
||||
public void setAcademicCalendar(AcademicCalendar academicCalendar) {
|
||||
this.academicCalendar = academicCalendar;
|
||||
}
|
||||
|
||||
public Integer getCourseNumber() {
|
||||
return courseNumber;
|
||||
}
|
||||
|
||||
public void setCourseNumber(Integer courseNumber) {
|
||||
this.courseNumber = courseNumber;
|
||||
}
|
||||
|
||||
public LocalDate getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(LocalDate date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getWeekNumber() {
|
||||
return weekNumber;
|
||||
}
|
||||
|
||||
public void setWeekNumber(Integer weekNumber) {
|
||||
this.weekNumber = weekNumber;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public AcademicCalendarActivityType getActivityType() {
|
||||
return activityType;
|
||||
}
|
||||
|
||||
public void setActivityType(AcademicCalendarActivityType activityType) {
|
||||
this.activityType = activityType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_years")
|
||||
public class AcademicYear {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 20)
|
||||
private String title;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_refresh_tokens")
|
||||
public class AuthRefreshToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
@Column(name = "rotated_to_token_hash", length = 64)
|
||||
private String rotatedToTokenHash;
|
||||
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "ip_address", length = 64)
|
||||
private String ipAddress;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getTenant() {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public void setTenant(String tenant) {
|
||||
this.tenant = tenant;
|
||||
}
|
||||
|
||||
public String getTokenHash() {
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
public void setTokenHash(String tokenHash) {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getIssuedAt() {
|
||||
return issuedAt;
|
||||
}
|
||||
|
||||
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||
this.issuedAt = issuedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
public String getRotatedToTokenHash() {
|
||||
return rotatedToTokenHash;
|
||||
}
|
||||
|
||||
public void setRotatedToTokenHash(String rotatedToTokenHash) {
|
||||
this.rotatedToTokenHash = rotatedToTokenHash;
|
||||
}
|
||||
|
||||
public String getUserAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public void setUserAgent(String userAgent) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return ipAddress;
|
||||
}
|
||||
|
||||
public void setIpAddress(String ipAddress) {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
public boolean isActive(LocalDateTime now) {
|
||||
return revokedAt == null && expiresAt.isAfter(now);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "classrooms")
|
||||
public class Classroom {
|
||||
public class Classroom extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -18,6 +18,11 @@ public class Classroom {
|
||||
@Column(nullable = false)
|
||||
private Integer capacity;
|
||||
|
||||
@Column(length = 50)
|
||||
private String building;
|
||||
|
||||
private Integer floor;
|
||||
|
||||
@Column(name = "is_available", nullable = false)
|
||||
private Boolean isAvailable = true;
|
||||
|
||||
@@ -52,6 +57,22 @@ public class Classroom {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public String getBuilding() {
|
||||
return building;
|
||||
}
|
||||
|
||||
public void setBuilding(String building) {
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
public Integer getFloor() {
|
||||
return floor;
|
||||
}
|
||||
|
||||
public void setFloor(Integer floor) {
|
||||
this.floor = floor;
|
||||
}
|
||||
|
||||
public Boolean getIsAvailable() {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal file
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name="departments")
|
||||
public class Department extends LifecycleEntity {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "education_forms")
|
||||
public class EducationForm {
|
||||
public class EducationForm extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "equipments")
|
||||
public class Equipment {
|
||||
public class Equipment extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "lessons")
|
||||
public class Lesson {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "teacher_id", nullable = false)
|
||||
private Long teacherId;
|
||||
|
||||
@Column(name = "group_id", nullable = false)
|
||||
private Long groupId;
|
||||
|
||||
@Column(name = "subject_id", nullable = false)
|
||||
private Long subjectId;
|
||||
|
||||
@Column(name = "lesson_format", nullable = false, length = 255)
|
||||
private String lessonFormat;
|
||||
|
||||
@Column(name = "type_lesson", nullable = false, length = 255)
|
||||
private String typeLesson;
|
||||
|
||||
@Column(name = "classroom_id", nullable = false)
|
||||
private Long classroomId;
|
||||
|
||||
@Column(name = "day", nullable = false, length = 255)
|
||||
private String day;
|
||||
|
||||
@Column(name = "week", nullable = false, length = 255)
|
||||
private String week;
|
||||
|
||||
@Column(name = "time", nullable = false, length = 255)
|
||||
private String time;
|
||||
|
||||
public Lesson() {
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
return teacherId;
|
||||
}
|
||||
|
||||
public void setTeacherId(Long teacherId) {
|
||||
this.teacherId = teacherId;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(Long groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public Long getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
public void setSubjectId(Long subjectId) {
|
||||
this.subjectId = subjectId;
|
||||
}
|
||||
|
||||
public String getLessonFormat() {
|
||||
return lessonFormat;
|
||||
}
|
||||
|
||||
public void setLessonFormat(String lessonFormat) {
|
||||
this.lessonFormat = lessonFormat;
|
||||
}
|
||||
|
||||
public String getTypeLesson() {
|
||||
return typeLesson;
|
||||
}
|
||||
|
||||
public void setTypeLesson(String typeLesson) {
|
||||
this.typeLesson = typeLesson;
|
||||
}
|
||||
|
||||
public Long getClassroomId() {
|
||||
return classroomId;
|
||||
}
|
||||
|
||||
public void setClassroomId(Long classroomId) {
|
||||
this.classroomId = classroomId;
|
||||
}
|
||||
|
||||
public String getDay() {
|
||||
return day;
|
||||
}
|
||||
|
||||
public void setDay(String day) {
|
||||
this.day = day;
|
||||
}
|
||||
|
||||
public String getWeek() {
|
||||
return week;
|
||||
}
|
||||
|
||||
public void setWeek(String week) {
|
||||
this.week = week;
|
||||
}
|
||||
|
||||
public String getTime() {
|
||||
return time;
|
||||
}
|
||||
|
||||
public void setTime(String time) {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal file
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal 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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user