Compare commits
91 Commits
personal-s
...
d2ef44471b
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/magistr/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
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,9 +7,7 @@ backend/build/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
.agents
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
AGENTS.md
|
skills-lock.json
|
||||||
GEMINI.md
|
|
||||||
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`.
|
||||||
@@ -4,9 +4,16 @@ COPY pom.xml .
|
|||||||
RUN mvn dependency:go-offline -B
|
RUN mvn dependency:go-offline -B
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN mvn package -DskipTests -B
|
RUN mvn package -DskipTests -B
|
||||||
|
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
|
||||||
|
|
||||||
FROM eclipse-temurin:17-jre-alpine
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
|
||||||
|
# Best practice: run as a non-root user
|
||||||
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
|
USER spring:spring
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/target/app.jar app.jar
|
COPY --from=build /app/target/app.jar app.jar
|
||||||
|
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Flyway Database Migrations -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
@@ -43,6 +49,20 @@
|
|||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-crypto</artifactId>
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OpenTelemetry API for custom span attributes -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.opentelemetry</groupId>
|
||||||
|
<artifactId>opentelemetry-api</artifactId>
|
||||||
|
<version>1.49.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package com.magistr.app;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
|
||||||
|
@EnableScheduling
|
||||||
public class Application {
|
public class Application {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,50 +1,39 @@
|
|||||||
package com.magistr.app.config;
|
package com.magistr.app.config;
|
||||||
|
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.config.tenant.TenantConfig;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.Optional;
|
/**
|
||||||
|
* При запуске приложения инициализирует БД для каждого тенанта.
|
||||||
|
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class DataInitializer implements CommandLineRunner {
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final TenantConfigWatcher configWatcher;
|
||||||
|
|
||||||
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
public DataInitializer(TenantRoutingDataSource routingDataSource,
|
||||||
this.userRepository = userRepository;
|
TenantConfigWatcher configWatcher) {
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.routingDataSource = routingDataSource;
|
||||||
|
this.configWatcher = configWatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
Optional<User> existing = userRepository.findByUsername("admin");
|
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
|
||||||
|
|
||||||
if (existing.isEmpty()) {
|
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
|
||||||
User admin = new User();
|
configWatcher.initDatabaseForTenant(tenant);
|
||||||
admin.setUsername("admin");
|
|
||||||
admin.setPassword(passwordEncoder.encode("admin"));
|
|
||||||
admin.setRole(Role.ADMIN);
|
|
||||||
userRepository.save(admin);
|
|
||||||
log.info("Created default admin user");
|
|
||||||
} else {
|
|
||||||
User admin = existing.get();
|
|
||||||
if (!passwordEncoder.matches("admin", admin.getPassword())) {
|
|
||||||
admin.setPassword(passwordEncoder.encode("admin"));
|
|
||||||
admin.setRole(Role.ADMIN);
|
|
||||||
userRepository.save(admin);
|
|
||||||
log.info("Reset admin password (hash was invalid)");
|
|
||||||
} else {
|
|
||||||
log.info("Admin user already exists with correct password");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("Database initialization complete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,33 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthSessionService {
|
||||||
|
|
||||||
|
private final Map<String, AuthenticatedUser> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public String createSession(User user) {
|
||||||
|
String token = UUID.randomUUID().toString();
|
||||||
|
sessions.put(token, new AuthenticatedUser(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getRole(),
|
||||||
|
user.getDepartmentId()
|
||||||
|
));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AuthenticatedUser> findByToken(String token) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(sessions.get(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,80 @@
|
|||||||
|
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 AuthSessionService sessionService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) {
|
||||||
|
this.sessionService = sessionService;
|
||||||
|
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 (request.getRequestURI().equals("/api/auth/login")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = bearerToken(request.getHeader("Authorization"));
|
||||||
|
AuthenticatedUser user = sessionService.findByToken(token).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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,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();
|
||||||
|
}
|
||||||
127
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
127
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
@@ -0,0 +1,127 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
|
||||||
|
*
|
||||||
|
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
|
||||||
|
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ConfigMapUpdater {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
|
||||||
|
|
||||||
|
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
|
||||||
|
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
|
||||||
|
private static final String CONFIGMAP_NAME = "tenants-config";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final boolean runningInK8s;
|
||||||
|
|
||||||
|
public ConfigMapUpdater() {
|
||||||
|
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||||
|
if (!runningInK8s) {
|
||||||
|
log.info("Not running in K8s — ConfigMap updates will be skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет ConfigMap tenants-config с новым списком тенантов.
|
||||||
|
* @return true если обновление успешно (или мы не в K8s)
|
||||||
|
*/
|
||||||
|
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||||
|
if (!runningInK8s) {
|
||||||
|
log.warn("Not in K8s, skipping ConfigMap update");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
|
||||||
|
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
|
||||||
|
|
||||||
|
// Формируем JSON для тенантов
|
||||||
|
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
|
||||||
|
.writeValueAsString(tenants);
|
||||||
|
|
||||||
|
// Strategic merge patch для ConfigMap
|
||||||
|
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"data", Map.of("tenants.json", tenantsJson)
|
||||||
|
));
|
||||||
|
|
||||||
|
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
|
||||||
|
K8S_API_BASE, namespace, CONFIGMAP_NAME);
|
||||||
|
|
||||||
|
// Создаём HttpClient с отключённой проверкой сертификатов
|
||||||
|
// (внутри кластера используется self-signed CA)
|
||||||
|
HttpClient client = createInsecureClient();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||||
|
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error updating ConfigMap: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
|
||||||
|
*/
|
||||||
|
private HttpClient createInsecureClient() {
|
||||||
|
try {
|
||||||
|
TrustManager[] trustAll = new TrustManager[]{
|
||||||
|
new X509TrustManager() {
|
||||||
|
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||||
|
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
|
||||||
|
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||||
|
sslContext.init(null, trustAll, new SecureRandom());
|
||||||
|
|
||||||
|
return HttpClient.newBuilder()
|
||||||
|
.sslContext(sslContext)
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to create insecure client, using default: {}", e.getMessage());
|
||||||
|
return HttpClient.newHttpClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
156
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
156
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
@@ -0,0 +1,156 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Периодически перечитывает tenants.json (mounted ConfigMap).
|
||||||
|
* Если ConfigMap был обновлён через K8s API, этот компонент
|
||||||
|
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
||||||
|
*
|
||||||
|
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TenantConfigWatcher {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||||
|
|
||||||
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
|
private final DataSource dataSource;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||||
|
private String lastConfigHash = "";
|
||||||
|
|
||||||
|
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
|
||||||
|
this.routingDataSource = routingDataSource;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||||
|
public void watchForChanges() {
|
||||||
|
try {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (!file.exists()) return;
|
||||||
|
|
||||||
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
|
String hash = Integer.toHexString(content.hashCode());
|
||||||
|
|
||||||
|
if (hash.equals(lastConfigHash)) {
|
||||||
|
return; // Ничего не изменилось
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
|
||||||
|
lastConfigHash = hash;
|
||||||
|
|
||||||
|
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||||
|
syncTenants(newTenants);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error watching tenants config: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
|
||||||
|
*/
|
||||||
|
public void refreshHash() {
|
||||||
|
try {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (file.exists()) {
|
||||||
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
|
lastConfigHash = Integer.toHexString(content.hashCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to refresh config hash: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизирует in-memory тенантов с конфигом из файла.
|
||||||
|
*/
|
||||||
|
private void syncTenants(List<TenantConfig> newTenants) {
|
||||||
|
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
|
||||||
|
Set<String> newDomains = newTenants.stream()
|
||||||
|
.map(t -> t.getDomain().toLowerCase())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// Добавить новые тенанты
|
||||||
|
for (TenantConfig tenant : newTenants) {
|
||||||
|
String domain = tenant.getDomain().toLowerCase();
|
||||||
|
if (!current.containsKey(domain)) {
|
||||||
|
log.info("Adding new tenant '{}' from ConfigMap update", domain);
|
||||||
|
routingDataSource.addTenant(tenant);
|
||||||
|
// Инициализируем БД для нового тенанта
|
||||||
|
initDatabaseForTenant(tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить тенанты, которых больше нет в конфиге
|
||||||
|
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||||
|
if (!newDomains.contains(existingDomain)) {
|
||||||
|
log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain);
|
||||||
|
routingDataSource.removeTenant(existingDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||||
|
* Если БД уже существует, но история Flyway пуста —
|
||||||
|
* делает baseline (считает V1_init.sql уже выполненным).
|
||||||
|
*/
|
||||||
|
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||||
|
String domain = tenant.getDomain();
|
||||||
|
try {
|
||||||
|
TenantContext.setCurrentTenant(domain);
|
||||||
|
|
||||||
|
log.info("[{}] Starting Flyway migrations...", domain);
|
||||||
|
|
||||||
|
// Получаем DataSource конкретно для этого тенанта
|
||||||
|
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||||
|
if (tenantDs == null) {
|
||||||
|
// Если ещё не resolve'нулся (первый запуск), берём обёртку
|
||||||
|
tenantDs = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
|
||||||
|
.dataSource(tenantDs)
|
||||||
|
.baselineOnMigrate(true)
|
||||||
|
.baselineVersion("1")
|
||||||
|
.load();
|
||||||
|
|
||||||
|
flyway.migrate();
|
||||||
|
log.info("[{}] Flyway migrations completed successfully", domain);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
||||||
|
} finally {
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
161
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
161
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||||
|
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||||
|
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация мультитенантного DataSource.
|
||||||
|
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||||
|
*
|
||||||
|
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||||
|
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class TenantDataSourceConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url:}")
|
||||||
|
private String defaultDbUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.username:}")
|
||||||
|
private String defaultDbUsername;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.password:}")
|
||||||
|
private String defaultDbPassword;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public DataSource dataSource() {
|
||||||
|
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||||
|
|
||||||
|
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||||
|
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||||
|
|
||||||
|
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||||
|
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||||
|
TenantConfig defaultTenant = new TenantConfig(
|
||||||
|
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||||
|
);
|
||||||
|
tenants.add(defaultTenant);
|
||||||
|
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем тенантов
|
||||||
|
for (TenantConfig tenant : tenants) {
|
||||||
|
try {
|
||||||
|
routingDataSource.addTenant(tenant);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||||
|
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||||
|
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||||
|
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||||
|
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||||
|
|
||||||
|
TenantConfig h2Fallback = new TenantConfig(
|
||||||
|
"H2 Placeholder", "default",
|
||||||
|
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||||
|
"sa", ""
|
||||||
|
);
|
||||||
|
routingDataSource.addTenant(h2Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return routingDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantRoutingDataSource tenantRoutingDataSource(DataSource dataSource) {
|
||||||
|
return (TenantRoutingDataSource) dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
|
||||||
|
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
|
||||||
|
em.setDataSource(dataSource);
|
||||||
|
em.setPackagesToScan("com.magistr.app.model");
|
||||||
|
|
||||||
|
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||||
|
vendorAdapter.setGenerateDdl(false);
|
||||||
|
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
||||||
|
em.setJpaVendorAdapter(vendorAdapter);
|
||||||
|
|
||||||
|
Map<String, Object> props = new HashMap<>();
|
||||||
|
props.put("hibernate.hbm2ddl.auto", "none");
|
||||||
|
props.put("hibernate.show_sql", "false");
|
||||||
|
em.setJpaPropertyMap(props);
|
||||||
|
|
||||||
|
return em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
|
||||||
|
return new JpaTransactionManager(emf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.springframework.context.annotation.Lazy
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private TenantRoutingDataSource tenantRoutingDataSource;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private AuthorizationInterceptor authorizationInterceptor;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
|
||||||
|
TenantInterceptor interceptor = new TenantInterceptor();
|
||||||
|
interceptor.setRoutingDataSource(routingDataSource);
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
|
||||||
|
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
|
||||||
|
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TenantConfig> loadTenantsFromFile() {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
log.info("Tenants config file not found: {}", tenantsConfigPath);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||||
|
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||||
|
return list;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read tenants config: {}", e.getMessage());
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.dto.LoginRequest;
|
import com.magistr.app.dto.LoginRequest;
|
||||||
import com.magistr.app.dto.LoginResponse;
|
import com.magistr.app.dto.LoginResponse;
|
||||||
|
import com.magistr.app.config.auth.AuthSessionService;
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
@@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@@ -18,16 +19,23 @@ public class AuthController {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final AuthSessionService sessionService;
|
||||||
|
|
||||||
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
||||||
"ADMIN", "/admin/",
|
"ADMIN", "/admin/",
|
||||||
|
"EDUCATION_OFFICE", "/admin/#schedule-view",
|
||||||
|
"DEPARTMENT", "/admin/#department-workspace",
|
||||||
|
"SCHEDULE_VIEWER", "/admin/#schedule-view",
|
||||||
"TEACHER", "/teacher/",
|
"TEACHER", "/teacher/",
|
||||||
"STUDENT", "/student/"
|
"STUDENT", "/student/"
|
||||||
);
|
);
|
||||||
|
|
||||||
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
public AuthController(UserRepository userRepository,
|
||||||
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
|
AuthSessionService sessionService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.sessionService = sessionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -38,14 +46,34 @@ public class AuthController {
|
|||||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(401)
|
.status(401)
|
||||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
|
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = userOpt.get();
|
User user = userOpt.get();
|
||||||
String token = UUID.randomUUID().toString();
|
if (user.isArchivedRecord()) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(401)
|
||||||
|
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||||
|
}
|
||||||
|
String token = sessionService.createSession(user);
|
||||||
String roleName = user.getRole().name();
|
String roleName = user.getRole().name();
|
||||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
||||||
|
Long departmentId = user.getDepartmentId();
|
||||||
|
|
||||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
|
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<?> me() {
|
||||||
|
var user = AuthContext.getCurrentUser();
|
||||||
|
if (user == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"userId", user.id(),
|
||||||
|
"username", user.username(),
|
||||||
|
"role", user.role().name(),
|
||||||
|
"departmentId", user.departmentId()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.ClassroomRequest;
|
import com.magistr.app.dto.ClassroomRequest;
|
||||||
import com.magistr.app.dto.ClassroomResponse;
|
import com.magistr.app.dto.ClassroomResponse;
|
||||||
import com.magistr.app.model.Classroom;
|
import com.magistr.app.model.Classroom;
|
||||||
import com.magistr.app.model.Equipment;
|
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.ClassroomRepository;
|
||||||
import com.magistr.app.repository.EquipmentRepository;
|
import com.magistr.app.repository.EquipmentRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -15,6 +18,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/classrooms")
|
@RequestMapping("/api/classrooms")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public class ClassroomController {
|
public class ClassroomController {
|
||||||
|
|
||||||
private final ClassroomRepository classroomRepository;
|
private final ClassroomRepository classroomRepository;
|
||||||
@@ -26,8 +30,12 @@ public class ClassroomController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<ClassroomResponse> getAllClassrooms() {
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
return classroomRepository.findAll().stream()
|
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)
|
.map(this::mapToResponse)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -47,10 +55,14 @@ public class ClassroomController {
|
|||||||
Classroom classroom = new Classroom();
|
Classroom classroom = new Classroom();
|
||||||
classroom.setName(request.getName().trim());
|
classroom.setName(request.getName().trim());
|
||||||
classroom.setCapacity(request.getCapacity());
|
classroom.setCapacity(request.getCapacity());
|
||||||
|
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||||
|
classroom.setFloor(request.getFloor());
|
||||||
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
||||||
|
|
||||||
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
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));
|
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,12 +92,23 @@ public class ClassroomController {
|
|||||||
classroom.setCapacity(request.getCapacity());
|
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) {
|
if (request.getIsAvailable() != null) {
|
||||||
classroom.setIsAvailable(request.getIsAvailable());
|
classroom.setIsAvailable(request.getIsAvailable());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.getEquipmentIds() != null) {
|
if (request.getEquipmentIds() != null) {
|
||||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
||||||
|
equipments = equipments.stream()
|
||||||
|
.filter(Equipment::isActiveRecord)
|
||||||
|
.toList();
|
||||||
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,15 +118,37 @@ public class ClassroomController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
|
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
|
||||||
if (!classroomRepository.existsById(id)) {
|
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
|
||||||
|
if (classroomOpt.isEmpty()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
classroomRepository.deleteById(id);
|
Classroom classroom = classroomOpt.get();
|
||||||
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
|
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) {
|
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()));
|
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,153 @@
|
|||||||
|
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.model.*;
|
||||||
|
import com.magistr.app.repository.SubjectCommentRepository;
|
||||||
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@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 ScheduleQueryService scheduleQueryService;
|
||||||
|
|
||||||
|
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||||
|
SubjectCommentRepository subjectCommentRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
ScheduleQueryService scheduleQueryService) {
|
||||||
|
this.subjectRepository = subjectRepository;
|
||||||
|
this.subjectCommentRepository = subjectCommentRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
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<?> getTeachers(@RequestParam(required = false) Long departmentId) {
|
||||||
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
|
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 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 && departmentId.equals(user.departmentId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.model.EducationForm;
|
import com.magistr.app.model.EducationForm;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||||
import com.magistr.app.repository.EducationFormRepository;
|
import com.magistr.app.repository.EducationFormRepository;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
import com.magistr.app.repository.GroupRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -12,20 +15,25 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/education-forms")
|
@RequestMapping("/api/education-forms")
|
||||||
|
@RequireRoles({Role.ADMIN})
|
||||||
public class EducationFormController {
|
public class EducationFormController {
|
||||||
|
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
|
private final AcademicCalendarRepository academicCalendarRepository;
|
||||||
|
|
||||||
public EducationFormController(EducationFormRepository educationFormRepository,
|
public EducationFormController(EducationFormRepository educationFormRepository,
|
||||||
GroupRepository groupRepository) {
|
GroupRepository groupRepository,
|
||||||
|
AcademicCalendarRepository academicCalendarRepository) {
|
||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
|
this.academicCalendarRepository = academicCalendarRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public List<Map<String, Object>> getAll() {
|
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()))
|
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -54,12 +62,17 @@ public class EducationFormController {
|
|||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any groups use this education form
|
// Нельзя удалить форму обучения, пока она используется группами или графиками.
|
||||||
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
|
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
|
||||||
if (!linked.isEmpty()) {
|
if (!linked.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
|
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
|
||||||
}
|
}
|
||||||
|
long linkedCalendars = academicCalendarRepository.countByStudyFormId(id);
|
||||||
|
if (linkedCalendars > 0) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")"));
|
||||||
|
}
|
||||||
|
|
||||||
educationFormRepository.deleteById(id);
|
educationFormRepository.deleteById(id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));
|
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.magistr.app.controller;
|
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.model.Equipment;
|
||||||
import com.magistr.app.repository.EquipmentRepository;
|
import com.magistr.app.repository.EquipmentRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -10,6 +13,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/equipments")
|
@RequestMapping("/api/equipments")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public class EquipmentController {
|
public class EquipmentController {
|
||||||
|
|
||||||
private final EquipmentRepository equipmentRepository;
|
private final EquipmentRepository equipmentRepository;
|
||||||
@@ -19,8 +23,8 @@ public class EquipmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Equipment> getAllEquipments() {
|
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
return equipmentRepository.findAll();
|
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -42,10 +46,23 @@ public class EquipmentController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteEquipment(@PathVariable Long 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();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
equipmentRepository.deleteById(id);
|
equipment.archive("Оборудование архивировано");
|
||||||
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.CreateGroupRequest;
|
import com.magistr.app.dto.CreateGroupRequest;
|
||||||
|
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||||
import com.magistr.app.dto.GroupResponse;
|
import com.magistr.app.dto.GroupResponse;
|
||||||
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
|
import com.magistr.app.model.AcademicYear;
|
||||||
import com.magistr.app.model.EducationForm;
|
import com.magistr.app.model.EducationForm;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.Speciality;
|
||||||
|
import com.magistr.app.model.SpecialtyProfile;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.repository.EducationFormRepository;
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
import com.magistr.app.repository.*;
|
||||||
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -15,69 +28,332 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/groups")
|
@RequestMapping("/api/groups")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
|
||||||
public class GroupController {
|
public class GroupController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||||
|
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
private final SpecialtiesRepository specialtiesRepository;
|
||||||
|
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||||
|
private final AcademicYearRepository academicYearRepository;
|
||||||
|
private final AcademicCalendarRepository academicCalendarRepository;
|
||||||
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
|
||||||
public GroupController(GroupRepository groupRepository,
|
public GroupController(GroupRepository groupRepository,
|
||||||
EducationFormRepository educationFormRepository) {
|
EducationFormRepository educationFormRepository,
|
||||||
|
SpecialtiesRepository specialtiesRepository,
|
||||||
|
SpecialtyProfileRepository specialtyProfileRepository,
|
||||||
|
AcademicYearRepository academicYearRepository,
|
||||||
|
AcademicCalendarRepository academicCalendarRepository,
|
||||||
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
|
ScheduleGeneratorService scheduleGeneratorService) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
|
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||||
|
this.academicYearRepository = academicYearRepository;
|
||||||
|
this.academicCalendarRepository = academicCalendarRepository;
|
||||||
|
this.assignmentRepository = assignmentRepository;
|
||||||
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<GroupResponse> getAllGroups() {
|
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
return groupRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех групп");
|
||||||
.map(g -> new GroupResponse(
|
|
||||||
g.getId(),
|
try {
|
||||||
g.getName(),
|
List<StudentGroup> groups = includeArchived
|
||||||
g.getGroupSize(),
|
? groupRepository.findAll()
|
||||||
g.getEducationForm().getId(),
|
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
g.getEducationForm().getName()))
|
|
||||||
|
List<GroupResponse> response = groups.stream()
|
||||||
|
.map(this::mapToResponse)
|
||||||
.toList();
|
.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.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
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
|
@PostMapping
|
||||||
|
@RequireRoles({Role.ADMIN})
|
||||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||||
if (request.getName() == null || request.getName().isBlank()) {
|
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
|
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
|
||||||
}
|
try {
|
||||||
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
|
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
|
if (validationError != null) {
|
||||||
}
|
return validationError;
|
||||||
if (request.getGroupSize() == null) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
|
|
||||||
}
|
|
||||||
if (request.getEducationFormId() == null) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||||
if (efOpt.isEmpty()) {
|
if (efOpt.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||||
}
|
}
|
||||||
|
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();
|
StudentGroup group = new StudentGroup();
|
||||||
group.setName(request.getName().trim());
|
group.setName(request.getName().trim());
|
||||||
group.setGroupSize(request.getGroupSize());
|
group.setGroupSize(request.getGroupSize());
|
||||||
group.setEducationForm(efOpt.get());
|
group.setEducationForm(efOpt.get());
|
||||||
|
group.setDepartmentId(request.getDepartmentId());
|
||||||
|
group.setYearStartStudy(request.getYearStartStudy());
|
||||||
|
group.setSpeciality(speciality);
|
||||||
|
group.setSpecialtyProfile(profile);
|
||||||
groupRepository.save(group);
|
groupRepository.save(group);
|
||||||
|
|
||||||
return ResponseEntity.ok(new GroupResponse(
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 validationError("Численность группы обязательна");
|
||||||
|
}
|
||||||
|
if (request.getEducationFormId() == null) {
|
||||||
|
return validationError("Форма обучения обязательна");
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
return validationError("ID кафедры обязателен");
|
||||||
|
}
|
||||||
|
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
||||||
|
return validationError("Год начала обучения обязателен");
|
||||||
|
}
|
||||||
|
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
||||||
|
return validationError("Код специальности обязателен");
|
||||||
|
}
|
||||||
|
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
||||||
|
return validationError("Профиль обучения обязателен");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<?> validationError(String errorMessage) {
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupResponse mapToResponse(StudentGroup group) {
|
||||||
|
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
||||||
|
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||||
|
Speciality speciality = group.getSpeciality();
|
||||||
|
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||||
|
return new GroupResponse(
|
||||||
group.getId(),
|
group.getId(),
|
||||||
group.getName(),
|
group.getName(),
|
||||||
group.getGroupSize(),
|
group.getGroupSize(),
|
||||||
group.getEducationForm().getId(),
|
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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@GetMapping("/{id}/calendar-assignments")
|
||||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
|
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
|
||||||
if (!groupRepository.existsById(id)) {
|
if (!groupRepository.existsById(id)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
groupRepository.deleteById(id);
|
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||||
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
.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,68 @@
|
|||||||
|
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.List;
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
||||||
|
groupId,
|
||||||
|
teacherId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(lessons);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("message", "Произошла ошибка при генерации расписания"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,63 @@
|
|||||||
|
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;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(scheduleQueryService.search(
|
||||||
|
groupId,
|
||||||
|
teacherId,
|
||||||
|
classroomId,
|
||||||
|
departmentId,
|
||||||
|
subjectId,
|
||||||
|
lessonTypeId,
|
||||||
|
timeSlotId,
|
||||||
|
parity,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
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("/{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;
|
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.model.Subject;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -10,8 +19,11 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/subjects")
|
@RequestMapping("/api/subjects")
|
||||||
|
@RequireRoles({Role.ADMIN})
|
||||||
public class SubjectController {
|
public class SubjectController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
|
||||||
|
|
||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
|
|
||||||
public SubjectController(SubjectRepository subjectRepository) {
|
public SubjectController(SubjectRepository subjectRepository) {
|
||||||
@@ -19,33 +31,129 @@ public class SubjectController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Subject> getAllSubjects() {
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
return subjectRepository.findAll();
|
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
|
@PostMapping
|
||||||
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
|
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
|
||||||
String name = request.get("name");
|
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
|
||||||
if (name == null || name.isBlank()) {
|
request.getName(), request.getCode(), request.getDepartmentId());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
|
|
||||||
|
try {
|
||||||
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
|
String errorMessage = "Название обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
}
|
}
|
||||||
if (subjectRepository.findByName(name.trim()).isPresent()) {
|
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
Subject subject = new Subject();
|
Subject subject = new Subject();
|
||||||
subject.setName(name.trim());
|
subject.setName(request.getName());
|
||||||
|
subject.setCode(request.getCode());
|
||||||
|
subject.setDepartmentId(request.getDepartmentId());
|
||||||
subjectRepository.save(subject);
|
subjectRepository.save(subject);
|
||||||
|
|
||||||
return ResponseEntity.ok(subject);
|
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new SubjectResponse(
|
||||||
|
subject.getId(),
|
||||||
|
subject.getName(),
|
||||||
|
subject.getCode(),
|
||||||
|
subject.getDepartmentId()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (Exception e){
|
||||||
|
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
||||||
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();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
subjectRepository.deleteById(id);
|
subject.archive("Дисциплина архивирована");
|
||||||
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
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;
|
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.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.TeacherSubject;
|
||||||
import com.magistr.app.model.TeacherSubjectId;
|
import com.magistr.app.model.TeacherSubjectId;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
@@ -14,6 +19,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/teacher-subjects")
|
@RequestMapping("/api/teacher-subjects")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||||
public class TeacherSubjectController {
|
public class TeacherSubjectController {
|
||||||
|
|
||||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||||
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TeacherSubjectResponse> getAll() {
|
public List<TeacherSubjectResponse> getAll() {
|
||||||
return teacherSubjectRepository.findAll().stream()
|
return teacherSubjectRepository.findAll().stream()
|
||||||
|
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||||
.map(ts -> new TeacherSubjectResponse(
|
.map(ts -> new TeacherSubjectResponse(
|
||||||
ts.getUserId(),
|
ts.getUserId(),
|
||||||
ts.getUser().getUsername(),
|
ts.getUser().getUsername(),
|
||||||
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
|
|||||||
if (userId == null || subjectId == null) {
|
if (userId == null || subjectId == null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
|
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", "Преподаватель не найден"));
|
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", "Дисциплина не найдена"));
|
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);
|
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||||
if (teacherSubjectRepository.existsById(id)) {
|
if (teacherSubjectRepository.existsById(id)) {
|
||||||
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||||
if (!teacherSubjectRepository.existsById(id)) {
|
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
|
||||||
|
if (teacherSubject == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
|
||||||
|
}
|
||||||
teacherSubjectRepository.deleteById(id);
|
teacherSubjectRepository.deleteById(id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
|
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,200 @@
|
|||||||
package com.magistr.app.controller;
|
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.CreateUserRequest;
|
||||||
|
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||||
|
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||||
import com.magistr.app.dto.UserResponse;
|
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.Role;
|
||||||
|
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||||
import com.magistr.app.model.User;
|
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 com.magistr.app.repository.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
|
@RequireRoles({Role.ADMIN})
|
||||||
public class UserController {
|
public class UserController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final DepartmentRepository departmentRepository;
|
||||||
|
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
public UserController(UserRepository userRepository,
|
||||||
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
|
DepartmentRepository departmentRepository,
|
||||||
|
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.departmentRepository = departmentRepository;
|
||||||
|
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<UserResponse> getAllUsers() {
|
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
return userRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех пользователей");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
try {
|
||||||
|
List<User> users = includeArchived
|
||||||
|
? userRepository.findAll()
|
||||||
|
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(u -> {
|
||||||
|
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
|
||||||
|
UserResponse userResponse = new UserResponse(
|
||||||
|
u.getId(),
|
||||||
|
u.getUsername(),
|
||||||
|
u.getRole().name(),
|
||||||
|
u.getFullName(),
|
||||||
|
u.getJobTitle(),
|
||||||
|
departmentName);
|
||||||
|
userResponse.setStatus(u.getStatus());
|
||||||
|
return userResponse;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
logger.info("Получено {} пользователей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
return userRepository.findByRole(Role.TEACHER).stream()
|
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
|
||||||
|
try {
|
||||||
|
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(u -> {
|
||||||
|
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
|
||||||
|
UserResponse userResponse = new UserResponse(
|
||||||
|
u.getId(),
|
||||||
|
u.getUsername(),
|
||||||
|
u.getRole().name(),
|
||||||
|
u.getFullName(),
|
||||||
|
u.getJobTitle(),
|
||||||
|
departmentName);
|
||||||
|
userResponse.setStatus(u.getStatus());
|
||||||
|
return userResponse;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
logger.info("Получено {} преподавателей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/teachers/{departmentId}")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
|
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||||
|
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||||
|
try {
|
||||||
|
List<User> users = userRepository.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);
|
||||||
|
|
||||||
|
List<UserResponse> userResponses = users.stream()
|
||||||
|
.map( user -> {
|
||||||
|
|
||||||
|
return new UserResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getRole().name(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getJobTitle(),
|
||||||
|
user.getDepartmentId()
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(userResponses);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Произошла ошибка при получении списка преподавателей");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||||
|
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||||
|
|
||||||
|
try {
|
||||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||||
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) {
|
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()) {
|
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;
|
Role role;
|
||||||
try {
|
try {
|
||||||
role = Role.valueOf(request.getRole());
|
role = Role.valueOf(request.getRole());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +202,142 @@ public class UserController {
|
|||||||
user.setUsername(request.getUsername());
|
user.setUsername(request.getUsername());
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
user.setRole(role);
|
user.setRole(role);
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setJobTitle(request.getJobTitle());
|
||||||
|
user.setDepartmentId(request.getDepartmentId());
|
||||||
userRepository.save(user);
|
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(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
||||||
|
} catch (Exception e ) {
|
||||||
|
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteUser(@PathVariable Long 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(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
||||||
|
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/department-history")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
|
||||||
|
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
|
||||||
if (!userRepository.existsById(id)) {
|
if (!userRepository.existsById(id)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
userRepository.deleteById(id);
|
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
|
||||||
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
.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(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
||||||
|
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
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::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 {
|
public class ClassroomRequest {
|
||||||
private String name;
|
private String name;
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
private String building;
|
||||||
|
private Integer floor;
|
||||||
private Boolean isAvailable;
|
private Boolean isAvailable;
|
||||||
private List<Long> equipmentIds;
|
private List<Long> equipmentIds;
|
||||||
|
|
||||||
@@ -24,6 +26,22 @@ public class ClassroomRequest {
|
|||||||
this.capacity = capacity;
|
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() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
return isAvailable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
import com.magistr.app.model.Equipment;
|
import com.magistr.app.model.Equipment;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class ClassroomResponse {
|
public class ClassroomResponse {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String name;
|
private String name;
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
private String building;
|
||||||
|
private Integer floor;
|
||||||
private Boolean isAvailable;
|
private Boolean isAvailable;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime archivedAt;
|
||||||
|
private String archiveReason;
|
||||||
private List<Equipment> equipments;
|
private List<Equipment> equipments;
|
||||||
|
|
||||||
public ClassroomResponse() {
|
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.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
|
this.building = building;
|
||||||
|
this.floor = floor;
|
||||||
this.isAvailable = isAvailable;
|
this.isAvailable = isAvailable;
|
||||||
|
this.status = status;
|
||||||
|
this.archivedAt = archivedAt;
|
||||||
|
this.archiveReason = archiveReason;
|
||||||
this.equipments = equipments;
|
this.equipments = equipments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +63,22 @@ public class ClassroomResponse {
|
|||||||
this.capacity = capacity;
|
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() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
return isAvailable;
|
||||||
}
|
}
|
||||||
@@ -53,6 +87,30 @@ public class ClassroomResponse {
|
|||||||
this.isAvailable = isAvailable;
|
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() {
|
public List<Equipment> getEquipments() {
|
||||||
return equipments;
|
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 String name;
|
||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
|
private Long departmentId;
|
||||||
|
private Integer yearStartStudy;
|
||||||
|
private Long specialtyId;
|
||||||
|
private Long specialtyProfileId;
|
||||||
|
private Long specialityCode;
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
@@ -29,4 +34,48 @@ public class CreateGroupRequest {
|
|||||||
public void setEducationFormId(Long educationFormId) {
|
public void setEducationFormId(Long educationFormId) {
|
||||||
this.educationFormId = educationFormId;
|
this.educationFormId = educationFormId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer 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 username;
|
||||||
private String password;
|
private String password;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public CreateUserRequest() {
|
public CreateUserRequest() {
|
||||||
}
|
}
|
||||||
@@ -32,4 +35,28 @@ public class CreateUserRequest {
|
|||||||
public void setRole(String role) {
|
public void setRole(String role) {
|
||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJobTitle(String jobTitle) {
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class GroupResponse {
|
public class GroupResponse {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -7,13 +10,44 @@ public class GroupResponse {
|
|||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
private String educationFormName;
|
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;
|
||||||
|
|
||||||
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) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.groupSize = groupSize;
|
this.groupSize = groupSize;
|
||||||
this.educationFormId = educationFormId;
|
this.educationFormId = educationFormId;
|
||||||
this.educationFormName = educationFormName;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -35,4 +69,44 @@ public class GroupResponse {
|
|||||||
public String getEducationFormName() {
|
public String getEducationFormName() {
|
||||||
return educationFormName;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 token;
|
||||||
private String role;
|
private String role;
|
||||||
private String redirect;
|
private String redirect;
|
||||||
|
private Long departmentId;
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
public LoginResponse() {
|
public LoginResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoginResponse(boolean success, String message, String token, String role, String redirect) {
|
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
|
||||||
|
this(success, 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.success = success;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
this.redirect = redirect;
|
this.redirect = redirect;
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
@@ -58,4 +66,20 @@ public class LoginResponse {
|
|||||||
public void setRedirect(String redirect) {
|
public void setRedirect(String redirect) {
|
||||||
this.redirect = redirect;
|
this.redirect = redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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,81 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class UserResponse {
|
public class UserResponse {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private String departmentName;
|
||||||
|
private Long departmentId;
|
||||||
|
private String status;
|
||||||
|
|
||||||
public UserResponse() {
|
public UserResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserResponse(Long id, String username, String role) {
|
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) {
|
||||||
|
this.id = id;
|
||||||
|
this.role = role;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
this.departmentId = departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUsername(String username) {
|
|
||||||
this.username = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getRole() {
|
public String getRole() {
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setRole(String role) {
|
public String getFullName() {
|
||||||
this.role = role;
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "classrooms")
|
@Table(name = "classrooms")
|
||||||
public class Classroom {
|
public class Classroom extends LifecycleEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -18,6 +18,11 @@ public class Classroom {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String building;
|
||||||
|
|
||||||
|
private Integer floor;
|
||||||
|
|
||||||
@Column(name = "is_available", nullable = false)
|
@Column(name = "is_available", nullable = false)
|
||||||
private Boolean isAvailable = true;
|
private Boolean isAvailable = true;
|
||||||
|
|
||||||
@@ -52,6 +57,22 @@ public class Classroom {
|
|||||||
this.capacity = capacity;
|
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() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
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
|
@Entity
|
||||||
@Table(name = "education_forms")
|
@Table(name = "education_forms")
|
||||||
public class EducationForm {
|
public class EducationForm extends LifecycleEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "equipments")
|
@Table(name = "equipments")
|
||||||
public class Equipment {
|
public class Equipment extends LifecycleEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
Normal file
104
backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
public abstract class LifecycleEntity {
|
||||||
|
|
||||||
|
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||||
|
public static final String STATUS_ARCHIVED = "ARCHIVED";
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status = STATUS_ACTIVE;
|
||||||
|
|
||||||
|
@Column(name = "active_from")
|
||||||
|
private LocalDate activeFrom;
|
||||||
|
|
||||||
|
@Column(name = "active_to")
|
||||||
|
private LocalDate activeTo;
|
||||||
|
|
||||||
|
@Column(name = "archived_at")
|
||||||
|
private LocalDateTime archivedAt;
|
||||||
|
|
||||||
|
@Column(name = "archive_reason")
|
||||||
|
private String archiveReason;
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status == null || status.isBlank() ? STATUS_ACTIVE : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getActiveFrom() {
|
||||||
|
return activeFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActiveFrom(LocalDate activeFrom) {
|
||||||
|
this.activeFrom = activeFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getActiveTo() {
|
||||||
|
return activeTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActiveTo(LocalDate activeTo) {
|
||||||
|
this.activeTo = activeTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getArchivedAt() {
|
||||||
|
return archivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||||
|
this.archivedAt = archivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getArchiveReason() {
|
||||||
|
return archiveReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArchiveReason(String archiveReason) {
|
||||||
|
this.archiveReason = archiveReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveRecord() {
|
||||||
|
return !STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isArchivedRecord() {
|
||||||
|
return STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveOn(LocalDate date) {
|
||||||
|
if (date == null) {
|
||||||
|
return isActiveRecord();
|
||||||
|
}
|
||||||
|
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
||||||
|
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void archive(String reason) {
|
||||||
|
status = STATUS_ARCHIVED;
|
||||||
|
archivedAt = LocalDateTime.now();
|
||||||
|
activeTo = LocalDate.now();
|
||||||
|
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void restore() {
|
||||||
|
status = STATUS_ACTIVE;
|
||||||
|
archivedAt = null;
|
||||||
|
archiveReason = null;
|
||||||
|
activeTo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
public enum Role {
|
public enum Role {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
|
EDUCATION_OFFICE,
|
||||||
|
DEPARTMENT,
|
||||||
|
SCHEDULE_VIEWER,
|
||||||
TEACHER,
|
TEACHER,
|
||||||
STUDENT
|
STUDENT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public enum ScheduleLessonCategory {
|
||||||
|
LECTURE("Лекции"),
|
||||||
|
LABORATORY("Лабораторные"),
|
||||||
|
PRACTICE("Практики");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
ScheduleLessonCategory(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ScheduleLessonCategory fromLessonType(LessonType lessonType) {
|
||||||
|
if (lessonType == null || lessonType.getLessonType() == null) {
|
||||||
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
|
}
|
||||||
|
String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU"));
|
||||||
|
if (name.contains("лекц")) {
|
||||||
|
return LECTURE;
|
||||||
|
}
|
||||||
|
if (name.contains("лаб")) {
|
||||||
|
return LABORATORY;
|
||||||
|
}
|
||||||
|
if (name.contains("практ")) {
|
||||||
|
return PRACTICE;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "schedule_overrides")
|
||||||
|
public class ScheduleOverride {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "base_rule_slot_id", nullable = false)
|
||||||
|
private ScheduleRuleSlot baseRuleSlot;
|
||||||
|
|
||||||
|
@Column(name = "lesson_date", nullable = false)
|
||||||
|
private LocalDate lessonDate;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "new_time_slot_id")
|
||||||
|
private TimeSlot newTimeSlot;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "new_classroom_id")
|
||||||
|
private Classroom newClassroom;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "new_teacher_id")
|
||||||
|
private User newTeacher;
|
||||||
|
|
||||||
|
@Column(name = "new_lesson_format", length = 30)
|
||||||
|
private String newLessonFormat;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String comment;
|
||||||
|
|
||||||
|
@Column(name = "created_by")
|
||||||
|
private Long createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScheduleRuleSlot getBaseRuleSlot() {
|
||||||
|
return baseRuleSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) {
|
||||||
|
this.baseRuleSlot = baseRuleSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getLessonDate() {
|
||||||
|
return lessonDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLessonDate(LocalDate lessonDate) {
|
||||||
|
this.lessonDate = lessonDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAction(String action) {
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSlot getNewTimeSlot() {
|
||||||
|
return newTimeSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewTimeSlot(TimeSlot newTimeSlot) {
|
||||||
|
this.newTimeSlot = newTimeSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Classroom getNewClassroom() {
|
||||||
|
return newClassroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewClassroom(Classroom newClassroom) {
|
||||||
|
this.newClassroom = newClassroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getNewTeacher() {
|
||||||
|
return newTeacher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewTeacher(User newTeacher) {
|
||||||
|
this.newTeacher = newTeacher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNewLessonFormat() {
|
||||||
|
return newLessonFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewLessonFormat(String newLessonFormat) {
|
||||||
|
this.newLessonFormat = newLessonFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getComment() {
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setComment(String comment) {
|
||||||
|
this.comment = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(Long createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
public enum ScheduleParity {
|
||||||
|
BOTH,
|
||||||
|
EVEN,
|
||||||
|
ODD
|
||||||
|
}
|
||||||
198
backend/src/main/java/com/magistr/app/model/ScheduleRule.java
Normal file
198
backend/src/main/java/com/magistr/app/model/ScheduleRule.java
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "schedule_rules")
|
||||||
|
public class ScheduleRule {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "subject_id", nullable = false)
|
||||||
|
private Subject subject;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "semester_id", nullable = false)
|
||||||
|
private Semester semester;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String status = LifecycleEntity.STATUS_ACTIVE;
|
||||||
|
|
||||||
|
@Column(name = "valid_from")
|
||||||
|
private LocalDate validFrom;
|
||||||
|
|
||||||
|
@Column(name = "valid_to")
|
||||||
|
private LocalDate validTo;
|
||||||
|
|
||||||
|
@Column(name = "lecture_academic_hours", nullable = false)
|
||||||
|
private Integer lectureAcademicHours = 0;
|
||||||
|
|
||||||
|
@Column(name = "laboratory_academic_hours", nullable = false)
|
||||||
|
private Integer laboratoryAcademicHours = 0;
|
||||||
|
|
||||||
|
@Column(name = "practice_academic_hours", nullable = false)
|
||||||
|
private Integer practiceAcademicHours = 0;
|
||||||
|
|
||||||
|
@Column(name = "lecture_start_week", nullable = false)
|
||||||
|
private Integer lectureStartWeek = 1;
|
||||||
|
|
||||||
|
@Column(name = "laboratory_start_week", nullable = false)
|
||||||
|
private Integer laboratoryStartWeek = 1;
|
||||||
|
|
||||||
|
@Column(name = "practice_start_week", nullable = false)
|
||||||
|
private Integer practiceStartWeek = 1;
|
||||||
|
|
||||||
|
@ManyToMany
|
||||||
|
@JoinTable(
|
||||||
|
name = "schedule_rule_groups",
|
||||||
|
joinColumns = @JoinColumn(name = "schedule_rule_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "group_id")
|
||||||
|
)
|
||||||
|
private Set<StudentGroup> groups = new HashSet<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private Set<ScheduleRuleSlot> slots = new HashSet<>();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Subject getSubject() {
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubject(Subject subject) {
|
||||||
|
this.subject = subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Semester getSemester() {
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSemester(Semester semester) {
|
||||||
|
this.semester = semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidFrom() {
|
||||||
|
return validFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidFrom(LocalDate validFrom) {
|
||||||
|
this.validFrom = validFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidTo() {
|
||||||
|
return validTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidTo(LocalDate validTo) {
|
||||||
|
this.validTo = validTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLectureAcademicHours() {
|
||||||
|
return lectureAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLectureAcademicHours(Integer lectureAcademicHours) {
|
||||||
|
this.lectureAcademicHours = lectureAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLaboratoryAcademicHours() {
|
||||||
|
return laboratoryAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) {
|
||||||
|
this.laboratoryAcademicHours = laboratoryAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPracticeAcademicHours() {
|
||||||
|
return practiceAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPracticeAcademicHours(Integer practiceAcademicHours) {
|
||||||
|
this.practiceAcademicHours = practiceAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLectureStartWeek() {
|
||||||
|
return lectureStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLectureStartWeek(Integer lectureStartWeek) {
|
||||||
|
this.lectureStartWeek = lectureStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLaboratoryStartWeek() {
|
||||||
|
return laboratoryStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLaboratoryStartWeek(Integer laboratoryStartWeek) {
|
||||||
|
this.laboratoryStartWeek = laboratoryStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPracticeStartWeek() {
|
||||||
|
return practiceStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPracticeStartWeek(Integer practiceStartWeek) {
|
||||||
|
this.practiceStartWeek = practiceStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int academicHoursFor(ScheduleLessonCategory category) {
|
||||||
|
return switch (category) {
|
||||||
|
case LECTURE -> valueOrZero(lectureAcademicHours);
|
||||||
|
case LABORATORY -> valueOrZero(laboratoryAcademicHours);
|
||||||
|
case PRACTICE -> valueOrZero(practiceAcademicHours);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public int startWeekFor(ScheduleLessonCategory category) {
|
||||||
|
return switch (category) {
|
||||||
|
case LECTURE -> valueOrDefault(lectureStartWeek, 1);
|
||||||
|
case LABORATORY -> valueOrDefault(laboratoryStartWeek, 1);
|
||||||
|
case PRACTICE -> valueOrDefault(practiceStartWeek, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int valueOrZero(Integer value) {
|
||||||
|
return valueOrDefault(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int valueOrDefault(Integer value, int defaultValue) {
|
||||||
|
return value == null ? defaultValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<StudentGroup> getGroups() {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGroups(Set<StudentGroup> groups) {
|
||||||
|
this.groups = groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<ScheduleRuleSlot> getSlots() {
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSlots(Set<ScheduleRuleSlot> slots) {
|
||||||
|
this.slots = slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "schedule_rule_slots")
|
||||||
|
public class ScheduleRuleSlot {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "schedule_rule_id", nullable = false)
|
||||||
|
private ScheduleRule scheduleRule;
|
||||||
|
|
||||||
|
@Column(name = "day_of_week", nullable = false)
|
||||||
|
private Integer dayOfWeek;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 10)
|
||||||
|
private ScheduleParity parity;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "time_slot_id", nullable = false)
|
||||||
|
private TimeSlot timeSlot;
|
||||||
|
|
||||||
|
@ManyToMany
|
||||||
|
@JoinTable(
|
||||||
|
name = "schedule_rule_slot_subgroups",
|
||||||
|
joinColumns = @JoinColumn(name = "schedule_rule_slot_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "subgroup_id")
|
||||||
|
)
|
||||||
|
private Set<Subgroup> subgroups = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "teacher_id", nullable = false)
|
||||||
|
private User teacher;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "classroom_id", nullable = false)
|
||||||
|
private Classroom classroom;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "lesson_type_id", nullable = false)
|
||||||
|
private LessonType lessonType;
|
||||||
|
|
||||||
|
@Column(name = "lesson_format", nullable = false, length = 30)
|
||||||
|
private String lessonFormat;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScheduleRule getScheduleRule() {
|
||||||
|
return scheduleRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScheduleRule(ScheduleRule scheduleRule) {
|
||||||
|
this.scheduleRule = scheduleRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDayOfWeek() {
|
||||||
|
return dayOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDayOfWeek(Integer dayOfWeek) {
|
||||||
|
this.dayOfWeek = dayOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScheduleParity getParity() {
|
||||||
|
return parity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setParity(ScheduleParity parity) {
|
||||||
|
this.parity = parity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSlot getTimeSlot() {
|
||||||
|
return timeSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeSlot(TimeSlot timeSlot) {
|
||||||
|
this.timeSlot = timeSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSubgroupId() {
|
||||||
|
return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Subgroup getSubgroup() {
|
||||||
|
return subgroups.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubgroup(Subgroup subgroup) {
|
||||||
|
subgroups.clear();
|
||||||
|
if (subgroup != null) {
|
||||||
|
subgroups.add(subgroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Subgroup> getSubgroups() {
|
||||||
|
return subgroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubgroups(Set<Subgroup> subgroups) {
|
||||||
|
this.subgroups.clear();
|
||||||
|
if (subgroups != null) {
|
||||||
|
this.subgroups.addAll(subgroups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> getSubgroupIds() {
|
||||||
|
return subgroups.stream()
|
||||||
|
.map(Subgroup::getId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getSubgroupNames() {
|
||||||
|
return subgroups.stream()
|
||||||
|
.map(Subgroup::getName)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getTeacher() {
|
||||||
|
return teacher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTeacher(User teacher) {
|
||||||
|
this.teacher = teacher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Classroom getClassroom() {
|
||||||
|
return classroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClassroom(Classroom classroom) {
|
||||||
|
this.classroom = classroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LessonType getLessonType() {
|
||||||
|
return lessonType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLessonType(LessonType lessonType) {
|
||||||
|
this.lessonType = lessonType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLessonFormat() {
|
||||||
|
return lessonFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLessonFormat(String lessonFormat) {
|
||||||
|
this.lessonFormat = lessonFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user