diff --git a/.agents/skills/auto-update-docs/SKILL.md b/.agents/skills/auto-update-docs/SKILL.md new file mode 100644 index 0000000..024c5f9 --- /dev/null +++ b/.agents/skills/auto-update-docs/SKILL.md @@ -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/`, обновить все вхождения diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -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 diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..78d2f00 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -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) в разных генерациях кода. + +**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения. + +Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению. diff --git a/.gitignore b/.gitignore index b362f1c..9647f9d 100755 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ backend/build/ frontend/node_modules/ frontend/dist/ -.agents .idea/ .vscode/ *.DS_Store -GEMINI.md \ No newline at end of file +skills-lock.json \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1e900b4..2d3d4b2 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,176 +26,64 @@ magistr/ │ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД) ├── frontend/ # Статические файлы │ ├── admin/ # Интерфейс администратора +│ │ └── settings/ # Страница настроек (отдельный SPA) │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента +├── docs/ # 📖 Документация проекта ├── compose.yaml # Docker Compose конфигурация └── .env # Переменные окружения ``` **Внешние зависимости (родительская директория)** -На уровень выше расположен `../caddy-proxy/`. Это реверс-прокси, обрабатывающий трафик для `magistr.zuev.company`. Если возникают проблемы с доменом или внешним доступом, проверяйте `Caddyfile` там. - -так же на уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там. +На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там. --- -## Команды сборки и запуска - -### Docker Compose (основной способ) - -Сборка и запуск всех сервисов (backend, frontend, PostgreSQL) выполняется через Docker Compose. +## Быстрый справочник команд ```bash -# Сборка и запуск всех сервисов +# Сборка и запуск docker compose up -d --build -# Остановка всех сервисов -docker compose down +# Полный сброс БД +docker compose down -v && docker compose up -d -# Просмотр логов всех сервисов -docker compose logs -f - -# Просмотр логов конкретного сервиса +# Логи конкретного сервиса docker compose logs -f backend - -# Пересоздать контейнер базы данных (полный сброс данных и повтор миграций Flyway) -docker compose down -v -docker compose up -d db ``` -### Frontend - -Статические файлы обслуживаются через Apache (httpd:alpine). Изменения в файлах frontend требуют пересборки контейнера. +Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md). --- -## Соглашения о коде (Code Style) +## Критические правила для агентов -### Java (Backend) - -**Именование:** -- Классы: PascalCase (например, `LessonsController`, `LessonResponse`) -- Методы и переменные: camelCase -- Константы: UPPER_SNAKE_CASE -- Пакеты: lowercase (например, `com.magistr.app.controller`) - -**Импорты:** -- Группировка: static imports, затем external packages, затем internal -- Используйте wildcard imports для пакетов того же модуля: `import com.magistr.app.model.*;` -- Порядок: java.*, javax.*, external.*, internal.* - -**Форматирование:** -- Отступы: 4 пробела (стандарт Java) -- Фигурные скобки: K&R style (открывающая на той же строке) -- Длина строки: до 120 символов -- Всегда используйте фигурные скобки для if/for/while - -**Типы и аннотации:** -- Используйте явные типы вместо `var` для возвращаемых значений публичных методов -- Аннотации JPA: `@Entity`, `@Table`, `@Id`, `@GeneratedValue`, `@Column` -- Используйте `@JsonInclude(JsonInclude.Include.NON_NULL)` для DTO -- Для логгирования используйте SLF4J: `LoggerFactory.getLogger(ClassName.class)` - -**Обработка ошибок:** -- Возвращайте `ResponseEntity` с соответствующим HTTP статусом -- Логируйте ошибки с полным стектрейсом: `logger.error("msg: {}", e.getMessage(), e)` -- Для валидации используйте отдельные классы-валидаторы (см. `DayAndWeekValidator`) - -**Архитектура контроллеров:** -- Используйте constructor injection для зависимостей -- Все endpoints имеют префикс `/api/` -- Возвращайте понятные сообщения об ошибках на русском языке - -### Frontend (JavaScript) - -**Именование:** -- Файлы: kebab-case (например, `main.js`, `schedule-view.js`) -- Функции и переменные: camelCase -- Константы: UPPER_SNAKE_CASE - -**Модули:** -- Используйте ES6 modules с `import`/`export` -- Всегда указывайте расширение при импорте: `import { x } from './api.js';` - -**Форматирование:** -- Отступы: 4 пробела -- Используйте template literals вместо конкатенации строк -- Предпочитайте `const` переменные, используйте `let` только при необходимости переприсваивания - -**Лучшие практики:** -- Используйте `async/await` для асинхронных операций -- Всегда обрабатывайте ошибки в блоках `catch` -- Используйте деструктуризацию объектов -- Кешируйте DOM-элементы в переменные - ---- - -## Работа с базой данных и мультитенантностью - -**Мультитенантность:** -- Приложение поддерживает множество клиентов (университетов). Каждый клиент имеет свою изолированную базу данных PostgreSQL. -- Маршрутизация к нужной БД происходит динамически на основе поддомена (`TenantInterceptor` -> `TenantContext` -> `TenantRoutingDataSource`). -- Список клиентов хранится в Kubernetes `ConfigMap` (`tenants-config`), который монтируется в под бэкенда как `/config/tenants.json`. -- Локально список берётся из файла `backend/tenants.json`. -- При добавлении нового клиента в интерфейсе `DatabaseController` через K8s API обновляет `ConfigMap`. Все реплики бэкенда заметят изменения и в фоне инициализируют новый пул соединений (`TenantConfigWatcher`). - -**Миграции схемы (Flyway):** -- Автогенерация Hibernate ОТКЛЮЧЕНА (`ddl-auto=none`). Структура баз данных управляется строго через **Flyway**. -- Все изменения схемы БД вносятся путем создания новых файлов в `backend/src/main/resources/db/migration/` (название строго `V2__add_new_table.sql` и т.д.). -- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`), которые уже закоммичены. Это сломает контрольные суммы Flyway. -- Flyway запускается программно при первом обращении к базе тенанта. Чтобы запустить Flyway для уже существующих тенантов (накатить V2), необходимо перезапустить бэкенд: `kubectl rollout restart deployment backend -n magistr`. -- Для локального сброса базы до изначального состояния: `docker compose down -v && docker compose up -d`. - -**Сущности и связи:** -- Foreign keys с `ON DELETE CASCADE` для поддержания целостности -- Используйте расширение `pgcrypto` для хеширования паролей (bcrypt) - ---- - -## Функциональные требования к системе (Бизнес-логика) - -### 1. Ролевая модель -- **Администратор (Деканат)**: Полный доступ, настройка топологии университета, управление аудиторным фондом, подтверждение переносов, регистрация инцидентов. -- **Преподаватель**: Просмотр своего расписания, подача заявок на перенос, отметка о своём отсутствии. -- **Студент**: Только просмотр расписания (Read-only). - -### 2. Управление ресурсами и топология -- **Управление аудиториями**: - - Указание вместимости. - - Привязка доступного оборудования (через сущность Equipments: Проектор, ПК, Лаборатория). - - Установка статуса "Не доступно" (блокирует назначение пар в этот период). -- **Управление группами**: - - Управление списком студентов (и возможность деления на подгруппы). -- **Управление дисциплинами**: - - Создание предметов и привязка их к преподавателям (какие дисциплины имеет право вести конкретный преподаватель). - -### 3. Логика расписания -- **Сетка**: 7 фиксированных слотов по 1.5 часа (08:00 - 09:30, и т.д.) + поддержка кастомного времени. -- **Проверка конфликтов**: - - *Критический конфликт*: Преподаватель не может находиться в двух разных аудиториях одновременно. - - *Уточнение по преподавателям*: Преподаватель может иметь несколько пар одновременно (для разных групп), только если они проходят в одной и той же аудитории (потоковая лекция). -- **Потоковые занятия**: - - Возможность назначить одну лекцию сразу нескольким группам (технически — несколько записей в БД или одна запись со списком групп). - - Проверка вместимости: вместимость аудитории должна покрывать суммарную численность всех групп, находящихся в этой аудитории в данный слот. - -### 4. Управление инцидентами (Инклюзия отсутствия) -- **Отсутствие (Sickness/Business Trip)**: Регистрация отсутствия преподавателя (с указанием причины и периода дат). -- **Обнаружение коллизий**: Автоматическая подсветка конфликтующих пар в расписании (Red Zone). -- **Система разрешения конфликтов (Resolution Wizard)**: - - Предложение подходящей замены преподавателя на этот слот. - - Предложение переноса занятия на другое время или в другую аудиторию. - ---- - -## Языковые требования +### Flyway миграции +- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. +- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` +- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) +### Языковые требования - **Все ответы и комментарии на русском языке** - Сообщения об ошибках и логи на русском - Пользовательский интерфейс на русском --- -## Существующие правила проекта +## Подробная документация -См. `.agent/rules/main.md` и `.agent/rules/database_schema.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) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) | diff --git a/SCHEDULE_PROPOSAL.md b/SCHEDULE_PROPOSAL.md new file mode 100644 index 0000000..3b8c6e0 --- /dev/null +++ b/SCHEDULE_PROPOSAL.md @@ -0,0 +1,178 @@ +# Концепция динамической генерации расписания + +Данный документ представляет собой подробное архитектурное описание новой системы управления расписанием. Система переходит от статического хранения каждой отдельной пары к параметрическому: мы сохраняем **правила проведения** дисциплины и **календарную сетку**, а фактическое расписание на любую дату вычисляется «на лету» (генерируется). + +> **Контекст миграции:** Новая система полностью заменяет существующие таблицы `lessons` (статическое расписание) и `schedule_data` (плановая нагрузка). Обе таблицы будут мигрированы в единую модель `schedule_rules` + `schedule_rule_slots`, которая совмещает хранение нагрузки (часы) и расписания (слоты) в одной структуре. + +--- + +## 1. Подробное описание компонентов системы + +Новая архитектура строится на строгом разделении данных на три логических слоя: Календарь (основа отсчета времени), Правила (шаблоны занятий) и Генератор (движок рендеринга фактического расписания). + +### 1.1 Справочная база времени (Календарный учебный график) +Чтобы система понимала, *когда* можно ставить пары, а когда нет, вводится понятие календарного графика. Он состоит из трёх взаимосвязанных сущностей: + +* **Академические периоды (Учебные года и Семестры).** Иерархия из двух уровней: + * **Учебный год** — контейнер с названием и датами (напр. «2024/2025», `01.09.2024` — `30.06.2025`). + * **Семестр** — дочерняя сущность учебного года. Содержит дату начала, от которой отсчитывается «Неделя 1» данного семестра. Нумерация недель начинается заново для каждого семестра. Тип семестра (`autumn` / `spring`) определяет, какой набор правил активен. + + Именно от даты начала семестра отсчитывается «Неделя 1». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели. + +* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок. + +* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность` → `Номер недели` → `Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия». + +### 1.2 Справочник временных слотов (Time Slots) +Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки. + +Слот содержит: +* `order_number` — порядковый номер пары в дне (1, 2, 3...). +* `start_time` — время начала (напр. `08:00`). +* `end_time` — время окончания (напр. `09:30`). +* `duration_minutes` — длительность пары в минутах. + +Это позволяет каждому университету настраивать количество и продолжительность пар без модификации кода. + +### 1.3 Движок правил (Schedule Rules) +Старый подход подразумевал, что каждая пара в базе (каждая клеточка) — это изолированная запись `lessons` («понедельник, 1-я пара, математика»). Новая система вводит сущность сводного **Правила Дисциплины**. Одно правило описывает расписание целого курса по конкретному предмету для одной или нескольких студенческих групп (включая потоковые лекции). + +**Базовые параметры (Лимиты Правила):** +* `subject_id` — ID преподаваемой дисциплины. +* `semester_id` — ID семестра, к которому привязано правило. Одна и та же дисциплина может читаться в разных семестрах с разными параметрами. +* `startDate` — Дата или номер недели семестра, с которой предмет начинает читаться (поскольку не все предметы идут строго с 1-й недели семестра). +* `totalHours` — Полный объём выделенных **академических часов** (1 ак. час = 45 минут; одна пара = 2 ак. часа). Это важнейший **лимитатор**, который обеспечивает автоматическую остановку генерации: как только заявленные часы будут вычитаны, предмет перестает отображаться в расписании студентов на последующих неделях. + +**Связь с группами (Many-to-Many):** +Одно правило может быть связано с несколькими группами через промежуточную таблицу `schedule_rule_groups`. Это обеспечивает поддержку **потоковых лекций** — когда один преподаватель читает лекцию нескольким группам одновременно в одной аудитории. При этом правило создаётся один раз, а группы к нему привязываются списком. + +**Массив паттернов (Слоты правила):** +Само «тело» правила разбивается на подчинённые слоты. Если предмет идёт в Пн и Ср, это будет 2 слота внутри одного Правила. Слот содержит: +* `dayOfWeek`: день недели (1–7, Пн–Вс). +* `parity`: тип четности — `ENUM('BOTH', 'EVEN', 'ODD')`. `BOTH` — каждую неделю, `EVEN` — по чётным (нижним) неделям, `ODD` — по нечётным (верхним). Конкретное соответствие «чётная = верхняя или нижняя» определяется настройкой тенанта. +* `time_slot_id`: FK на таблицу `time_slots` — порядковый номер и время пары. +* `subgroup_id`: FK на подгруппу (NULL = вся группа). *Это гарантирует, что мы сможем ставить разным подгруппам пересекающиеся занятия в разных аудиториях без алгоритмических конфликтов.* +* `teacher_id`: FK на преподавателя слота. +* `classroom_id`: FK на аудиторию слота. +* `lesson_type_id`: FK на тип занятия (`Лекция`, `Практическая работа`, `Лабораторная работа`). +* `lesson_format`: формат проведения (`Очно` / `Онлайн`). + +> **Обоснование:** Хранение `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` в **слотах**, а не в главном правиле, позволяет гибко описывать ситуации вроде: лекции в понедельник читает лектор Иванов (Аудитория 100), а лабораторные в среду ведёт практик Петров (Аудитория 102В) — в рамках одного правила по предмету «Программирование», расходуя общий `totalHours`. + +### 1.4 Генератор (Рендерер) расписания +Это слой бизнес-логики (служба `ScheduleGeneratorService` в Java), который работает исключительно в оперативной памяти бэкенда и производит расчёт расписания «on-demand» (по требованию) при запросе от клиента фронтенда. + +**Пошаговый алгоритм работы генератора:** +1. Фронтенд (Интерфейс пользователя) запрашивает: *«Дай мне расписание группы ИТ-21 на конкретный период, например, с 14 октября по 20 октября»*. +2. Генератор определяет семестр по запрошенным датам и вычисляет, что 14 октября соответствует, к примеру, 7-й неделе семестра (вычисление от `startDate` семестра). +3. Он сверяется с *Матрицей учебного графика*. Для этого генератор определяет текущий курс группы по формуле `текущий_учебный_год - year_start_study + 1` и находит `specialty_id` группы. Если у данного курса/специальности сейчас стоит `VACATION` (Каникулы) или `PRACTICE` (Практика), генератор сразу возвращает пустой ответ или ответ со статусом периода. +4. Если статус недели позволяет проводить занятия (`THEORY`), генератор поднимает из Базы Данных все активные **Правила** для запрошенной группы (через таблицу `schedule_rule_groups`), привязанные к текущему семестру. +5. **Механика Лимитатора часов:** Для каждого правила алгоритм «симулирует» прогон времени с даты старта правила до текущей запрошенной недели. Он подсчитывает количество успешно проведённых ак. часов (по 2 ак. часа за каждый отработанный слот), пропуская даты, попавшие в справочник праздников, и недели с типом деятельности отличным от `THEORY`. +6. Если у правила лимит `totalHours` достиг значения `0`, программа понимает, что курс вычитан, и предмет не отображается. Если часы ещё остались, алгоритм проецирует шаблоны (слоты правила) на запрошенную текущую неделю с учётом чётности, аудиторий и подгрупп, отдавая готовый JSON-массив в браузер пользователя. + +**Генерация расписания для преподавателя:** +Аналогричный алгоритм, но поиск правил идёт не по привязке к группе, а по `teacher_id` в слотах. Генератор собирает все `schedule_rule_slots`, где `teacher_id` = ID текущего преподавателя, получает родительские правила и рендерит расписание, обогащая каждую запись списком групп из `schedule_rule_groups`. + +**Кеширование:** +Для оптимизации производительности (т.к. симуляция прогона за весь семестр для каждого запроса ресурсоёмка) предусмотрен кеш: +* Список праздников текущего учебного года кешируется при первом обращении и инвалидируется при изменении таблицы `holidays`. +* Матрица учебного графика кешируется по ключу `(course, specialty_id, semester_id)`. +* Результаты подсчёта `consumed_hours` для каждого правила могут кешироваться с инвалидацией при изменении праздников или правил. + +--- + +## 2. Архитектурные Решения + +На основе обсуждений были задокументированы следующие концептуальные решения по архитектуре: + +1. **Реакция на праздники (Продление курса):** + Алгоритм воспринимает праздник как «пропуск хода», не отнимая проведённые часы от `totalHours`. Это означает, что пара **не переносится** на другой день или время — она просто пропускается без вычета часов. Фактически предмет будет отображаться в расписании дольше (больше недель), пока `totalHours` не будет полностью исчерпан. Преподаватель честно выработает положенный объём часов за счёт увеличения количества недель преподавания. + +2. **Нормализация через связанные таблицы:** + Мы не используем сырые массивы (`INTEGER[]`) или JSONB-колонки. Реализована структура со строгой нормализацией: + * Главная таблица: `schedule_rules` (хранит лимиты и дату старта). + * Подчинённая таблица: `schedule_rule_slots` (хранит конкретный день, чётность, номер пары, преподавателя, аудиторию, тип и формат — прикреплённые к ID главного правила через Foreign Key). + * Связующая таблица: `schedule_rule_groups` (Many-to-Many между правилом и группами). + Это позволяет базе данных строить сложные выборки в стиле «Покажи загруженность кабинета №21 во вторник на второй паре по чётным неделям», исключая тяжёлый парсинг JSON. + +3. **Поддержка подгрупп внутри слотов:** + В таблицу `schedule_rule_slots` введено поле `subgroup_id` (Id подгруппы, nullable). Алгоритм генератора сможет рендерить два предмета для одной группы одновременно и без конфликтов, если они ассоциированы с разными подгруппами одной материнской группы. + +4. **Обогащённые слоты (Вариант Б):** + `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` хранятся в каждой строке `schedule_rule_slots`, а не в главном правиле. Это позволяет описывать лекции и практики одного предмета в рамках одного правила, расходуя общий `totalHours`. + +5. **Потоковые лекции через Many-to-Many:** + Одно правило связывается с несколькими группами через `schedule_rule_groups`. Для потоковой лекции создаётся одно правило, к которому привязываются все участвующие группы. + +6. **Настраиваемость по тенантам:** + Архитектурно все тенанты одинаковы — каждый университет получает идентичную пустую базу данных. Временные слоты (количество, длительность, время начала/окончания пар), конвенция чётности и прочие параметры не требуют специального механизма: каждый университет просто заполняет свою БД самостоятельно через панель администратора. + +--- + +## 3. Подробный План Действий по Реализации + +Интеграция новой архитектуры затронет весь стек приложения (DB → Backend → API → Frontend). Работу предлагается вести строго поэтапно: + +### Этап 1. База Данных (Flyway Миграции) + +**Схема Временных слотов:** +* `time_slots` (id, order_number, start_time TIME, end_time TIME, duration_minutes INT). + * Заполняется администратором. Нет фиксированных значений — каждый тенант настраивает свою сетку пар. + +**Схема Календарного графика:** +* `academic_years` (id, title VARCHAR, start_date DATE, end_date DATE). +* `semesters` (id, academic_year_id FK, semester_type ENUM('autumn','spring'), start_date DATE, end_date DATE). + * Именно от `semesters.start_date` отсчитывается «Неделя 1». +* `holidays` (id, date DATE, academic_year_id FK, description VARCHAR). +* `academic_calendar_matrix` (id, semester_id FK, course_number INT, specialty_id FK, week_number INT, activity_type ENUM('THEORY','EXAM','VACATION','PRACTICE')). + * Привязка к `course_number` + `specialty_id`, а НЕ к конкретной группе. + +**Схема Движка Правил:** +* `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date DATE, total_academic_hours INT). + * `total_academic_hours` — в академических часах (1 ак. час = 45 мин, одна пара = 2 ак. часа). +* `schedule_rule_groups` (schedule_rule_id FK, group_id FK) — PK составной. + * Связующая таблица для потоковых лекций. +* `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week INT CHECK(1–7), parity ENUM('BOTH','EVEN','ODD'), time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format VARCHAR). + +**Скрипт Миграции (Data ETL):** Написание SQL/Java скрипта для миграции данных из двух источников: +1. **Из `schedule_data`** → `schedule_rules` + `schedule_rule_groups`: перенос плановой нагрузки (`number_of_hours` → `total_academic_hours`, `group_id`, `subjects_id`, `teacher_id`, `lesson_type_id`, `is_division`, `semester_type`, `period`). +2. **Из `lessons`** → `schedule_rule_slots`: перенос расписания с трансформацией данных: + * `day` (строка «Понедельник»...«Суббота») → `day_of_week` (INT 1–6). + * `time` (строка «8:00 - 9:30») → `time_slot_id` (FK на `time_slots`). + * `week` (строка «Верхняя»/«Нижняя»/«Обе») → `parity` (ENUM `ODD`/`EVEN`/`BOTH`). + * Группировка записей с одинаковым `(subject_id, group_id)` в одно правило. + +После успешной миграции и верификации данных — удаление таблиц `lessons` и `schedule_data`. + +### Этап 2. Бэкенд и Вычислительное Ядро (Java + Spring Boot) +* `AcademicDateService.java` — сервис утилит для календарной математики: + * Перевод дат в номер недели семестра. + * Определение чётности недели с учётом настройки тенанта. + * Проверка попадания дня в справочник `holidays`. + * Вычисление текущего курса группы: `текущий_учебный_год - year_start_study + 1`. +* `ScheduleRuleRepository.java` — JPA репозитории для извлечения графа правил из базы данных, с оптимизацией N+1 проблемы через `JOIN FETCH` со слотами и группами. +* `ScheduleGeneratorService.java` — Сердце системы. Основные методы: + * `List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы. + * `List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate)` — расписание преподавателя (поиск по `teacher_id` в слотах, обогащение информацией о группах). + * Реализует всю бизнес-логику из пункта 1.4 (подсчёт вычитанных часов, пропуск праздников, кеширование). +* Адаптация валидаторов пересечения аудиторий: теперь валидатор должен работать не на уровне «каждой пары», а симулировать весь семестр на этапе сохранения нового Правила в панели администратора. + +### Этап 3. Обновление REST API (Контроллеры) +* **Новый эндпоинт расписания:** `GET /api/schedule` переходит на диапазонную модель. Параметры: `?groupId=123&startDate=2024-10-14&endDate=2024-10-20` или `?teacherId=456&startDate=...&endDate=...`. Ответ — массив объектов с полными датами `YYYY-MM-DD`. +* **Обратная совместимость:** Старый эндпоинт `GET /api/users/lessons` будет помечен как `@Deprecated` и продолжит работать до полной миграции фронтенда. После завершения миграции фронтенда — удаление. +* **CRUD-контроллеры для админки:** + * `/api/admin/time-slots` (настройка сетки временных слотов). + * `/api/admin/calendar/years` (учебные годы и семестры). + * `/api/admin/calendar/matrix` (настройка каникул и сессий по курсам/специальностям/неделям). + * `/api/admin/calendar/holidays` (добавление исключений). + * `/api/admin/schedule-rules` (управление жизненным циклом Правил, их слотами и привязкой к группам). + +### Этап 4. Интерфейсы Frontend (Vanilla JS + HTML) +* **Страницы просмотра (Студенты и Преподаватели):** + * Реализация переключателя календарных дат (Date Picker или кнопки-перелистывания недель). + * Логика, которая при свайпе или клике запрашивает у API конкретный диапазон дат и перерисовывает DOM-дерево. + * Для преподавателей — отображение всех групп, привязанных к каждому занятию. +* **Панель Администратора (SPA-интерфейсы):** + * **Вкладка «Временные слоты»:** Настройка сетки пар — количество, время начала/окончания, длительность. + * **Вкладка «Учебный график»:** Визуальная сетка-матрица (недели по горизонтали, Курсы/Специальности по вертикали), где админ может закрашивать пересечения разными цветами, назначая статусы (Практика, Каникулы, Теория, Экзамены). + * **Вкладка «Конструктор Правил»:** Глобально новый визуальный инструмент расписания. Админ выбирает Группы (одну или несколько для потока) и Дисциплину, задаёт `totalHours` в академических часах, а затем динамически добавляет строчки массива слотов через кнопку «Добавить занятие» со списками (Selects) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия. diff --git a/SCHEDULE_TASKS.md b/SCHEDULE_TASKS.md new file mode 100644 index 0000000..30c600d --- /dev/null +++ b/SCHEDULE_TASKS.md @@ -0,0 +1,218 @@ +# 📋 Задачи: Динамическая генерация расписания + +> Декомпозиция [`SCHEDULE_PROPOSAL.md`](SCHEDULE_PROPOSAL.md) на подзадачи для доски планирования. +> Категории: **Backend**, **Frontend**, **DevOps/DB** + +--- + +## DevOps / Database + +### Flyway-миграция: Временные слоты +- [ ] Создать миграцию: таблица `time_slots` (id, order_number, start_time, end_time, duration_minutes) +- [ ] Добавить CHECK-ограничения (start_time < end_time, duration_minutes > 0, order_number > 0) + +--- + +### Flyway-миграция: Учебные годы и семестры +- [ ] Создать миграцию: таблица `academic_years` (id, title, start_date, end_date) +- [ ] Создать миграцию: таблица `semesters` (id, academic_year_id FK, semester_type ENUM, start_date, end_date) +- [ ] Добавить CHECK-ограничения и индексы + +--- + +### Flyway-миграция: Праздники +- [ ] Создать миграцию: таблица `holidays` (id, date, academic_year_id FK, description) +- [ ] Добавить уникальный индекс на (date, academic_year_id) + +--- + +### Flyway-миграция: Матрица учебного графика +- [ ] Создать миграцию: таблица `academic_calendar_matrix` (id, semester_id FK, course_number, specialty_id FK, week_number, activity_type ENUM) +- [ ] Добавить ENUM: `THEORY`, `EXAM`, `VACATION`, `PRACTICE` +- [ ] Добавить уникальный индекс на (semester_id, course_number, specialty_id, week_number) + +--- + +### Flyway-миграция: Правила расписания +- [ ] Создать миграцию: таблица `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date, total_academic_hours) +- [ ] Создать миграцию: связующая таблица `schedule_rule_groups` (schedule_rule_id FK, group_id FK, PK составной) +- [ ] Создать миграцию: таблица `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week, parity ENUM, time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format) +- [ ] Добавить CHECK на day_of_week (1–7) +- [ ] Добавить ENUM: `BOTH`, `EVEN`, `ODD` + +--- + +### ETL-миграция данных +- [ ] Написать SQL/Java скрипт миграции `schedule_data` → `schedule_rules` + `schedule_rule_groups` + - [ ] Маппинг `number_of_hours` → `total_academic_hours` + - [ ] Маппинг привязок групп +- [ ] Написать SQL/Java скрипт миграции `lessons` → `schedule_rule_slots` + - [ ] Трансформация `day` (строка) → `day_of_week` (INT 1–6) + - [ ] Трансформация `time` (строка) → `time_slot_id` (FK) + - [ ] Трансформация `week` (строка) → `parity` (ENUM) + - [ ] Группировка записей с одинаковым (subject_id, group_id) в одно правило +- [ ] Верификация мигрированных данных (количество записей, целостность FK) +- [ ] Создать миграцию на удаление устаревших таблиц `lessons` и `schedule_data` (после верификации) + +--- + +## Backend (Java + Spring Boot) + +### JPA-сущности (Model) +- [ ] Создать Entity: `TimeSlot` +- [ ] Создать Entity: `AcademicYear` +- [ ] Создать Entity: `Semester` (связь ManyToOne → AcademicYear) +- [ ] Создать Entity: `Holiday` (связь ManyToOne → AcademicYear) +- [ ] Создать Entity: `AcademicCalendarMatrix` (связи на Semester, Specialty) +- [ ] Создать Entity: `ScheduleRule` (связи на Subject, Semester) +- [ ] Создать Entity: `ScheduleRuleSlot` (связи на ScheduleRule, TimeSlot, Teacher, Classroom, LessonType) +- [ ] Настроить ManyToMany-связь ScheduleRule ↔ StudentGroup через `schedule_rule_groups` + +--- + +### DTO +- [ ] Создать DTO: `TimeSlotDto` +- [ ] Создать DTO: `AcademicYearDto`, `SemesterDto` +- [ ] Создать DTO: `HolidayDto` +- [ ] Создать DTO: `AcademicCalendarMatrixDto` +- [ ] Создать DTO: `ScheduleRuleDto`, `ScheduleRuleSlotDto` +- [ ] Создать DTO: `RenderedLessonDto` (ответ генератора расписания) + +--- + +### Repository +- [ ] Создать `TimeSlotRepository` +- [ ] Создать `AcademicYearRepository` +- [ ] Создать `SemesterRepository` (метод findByDateRange) +- [ ] Создать `HolidayRepository` (метод findByAcademicYearId) +- [ ] Создать `AcademicCalendarMatrixRepository` (метод findBySemesterAndCourseAndSpecialty) +- [ ] Создать `ScheduleRuleRepository` с JOIN FETCH (решение N+1 проблемы) + - [ ] Метод: findByGroupIdAndSemesterId (через schedule_rule_groups) + - [ ] Метод: findByTeacherIdAndSemesterId (через schedule_rule_slots.teacher_id) + +--- + +### Сервис: AcademicDateService +- [ ] Метод: перевод произвольной даты → номер недели семестра +- [ ] Метод: определение чётности недели с учётом настройки тенанта +- [ ] Метод: проверка попадания даты в справочник `holidays` +- [ ] Метод: вычисление текущего курса группы (`текущий_учебный_год - year_start_study + 1`) +- [ ] Метод: определение семестра по дате +- [ ] Написать юнит-тесты для AcademicDateService + +--- + +### Сервис: ScheduleGeneratorService +- [ ] Метод: `buildScheduleForGroup(groupId, startDate, endDate)` — расписание группы + - [ ] Определение семестра по диапазону дат + - [ ] Вычисление номера недели и курса группы + - [ ] Проверка типа деятельности через матрицу графика + - [ ] Загрузка активных правил для группы + - [ ] Симуляция прогона часов (подсчёт consumed_hours) + - [ ] Пропуск праздников при подсчёте часов + - [ ] Проекция слотов на запрошенную неделю с учётом чётности и подгрупп +- [ ] Метод: `buildScheduleForTeacher(teacherId, startDate, endDate)` — расписание преподавателя + - [ ] Поиск правил по teacher_id в слотах + - [ ] Обогащение ответа списком групп из schedule_rule_groups +- [ ] Написать юнит-тесты для ScheduleGeneratorService +- [ ] Написать интеграционные тесты (полный цикл с тестовой БД) + +--- + +### Кеширование +- [ ] Реализовать кеш списка праздников по учебному году +- [ ] Реализовать кеш матрицы учебного графика по ключу (course, specialty_id, semester_id) +- [ ] Реализовать кеш consumed_hours для каждого правила +- [ ] Реализовать инвалидацию кеша праздников при CRUD-операциях с holidays +- [ ] Реализовать инвалидацию кеша consumed_hours при изменении правил или праздников + +--- + +### Валидация +- [ ] Адаптировать валидатор пересечения аудиторий (симуляция всего семестра при сохранении правила) +- [ ] Валидация пересечения преподавателей (один преподаватель не может вести две пары одновременно) +- [ ] Валидация пересечения групп (одна группа не может быть на двух занятиях одновременно, кроме подгрупп) + +--- + +### REST API: Контроллеры +- [ ] `GET /api/schedule` — Новый эндпоинт расписания (параметры: groupId/teacherId + startDate + endDate) +- [ ] Пометить `GET /api/users/lessons` как `@Deprecated` (обратная совместимость) +- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/time-slots` +- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/years` +- [ ] CRUD: `GET/PUT /api/admin/calendar/semesters` (вложены в years) +- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/holidays` +- [ ] CRUD: `GET/PUT /api/admin/calendar/matrix` (массовое сохранение матрицы) +- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/schedule-rules` + - [ ] Включая вложенные слоты и привязку групп +- [ ] Написать интеграционные тесты для API + +--- + +### Удаление устаревшего кода +- [ ] Удалить/рефакторить старый `LessonsController` (после миграции фронтенда) +- [ ] Удалить/рефакторить старый `ScheduleDataController` +- [ ] Удалить старые Entity: `Lesson`, `ScheduleData` +- [ ] Удалить старые Repository и Service для lessons/schedule_data + +--- + +## Frontend (Vanilla JS + HTML/CSS) + +### Просмотр расписания: Студенты +- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) +- [ ] Переключить API-запросы на новый `GET /api/schedule?groupId=...&startDate=...&endDate=...` +- [ ] Рендеринг расписания по дням и временным слотам +- [ ] Отображение статуса периода (Каникулы / Практика / Экзамены), если неделя не учебная +- [ ] Отображение информации о подгруппах (два занятия рядом для разных подгрупп) + +--- + +### Просмотр расписания: Преподаватели +- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) +- [ ] Переключить API-запросы на новый `GET /api/schedule?teacherId=...&startDate=...&endDate=...` +- [ ] Отображение всех групп, привязанных к каждому занятию +- [ ] Отображение подгрупп, если преподаватель ведёт у подгруппы + +--- + +### Панель администратора: Вкладка «Временные слоты» +- [ ] Создать UI-страницу настройки временных слотов +- [ ] CRUD-интерфейс: добавление/редактирование/удаление пар +- [ ] Отображение таблицы: номер пары → время начала → время окончания → длительность +- [ ] Валидация на фронтенде (пересечение времён, корректность данных) + +--- + +### Панель администратора: Вкладка «Учебный график» +- [ ] Создать UI: выбор учебного года и семестра +- [ ] Создать UI: CRUD учебных годов и семестров +- [ ] Создать UI: CRUD праздников (список дат с описанием) +- [ ] Создать визуальную сетку-матрицу: + - [ ] Горизонтальная ось — номера недель + - [ ] Вертикальная ось — Курс + Специальность + - [ ] Цветовая кодировка ячеек: Теория/Экзамены/Каникулы/Практика + - [ ] Клик/драг для массового назначения статуса +- [ ] Сохранение матрицы через API `PUT /api/admin/calendar/matrix` + +--- + +### Панель администратора: Вкладка «Конструктор Правил» +- [ ] Создать UI: список существующих правил с фильтрацией (по группе, предмету, семестру) +- [ ] Форма создания/редактирования правила: + - [ ] Мультиселект групп (для потоковых лекций) + - [ ] Выбор дисциплины (subject) + - [ ] Выбор семестра + - [ ] Ввод totalHours (академические часы) + - [ ] Ввод даты начала (active_from_date) +- [ ] Динамический массив слотов (кнопка «Добавить занятие»): + - [ ] Select: День недели + - [ ] Select: Временной слот (из таблицы time_slots) + - [ ] Select: Чётность (Обе/Чётная/Нечётная) + - [ ] Select: Подгруппа (опционально) + - [ ] Select: Преподаватель + - [ ] Select: Аудитория + - [ ] Select: Тип занятия (Лекция/Практика/Лаба) + - [ ] Select: Формат (Очно/Онлайн) +- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты) +- [ ] Удаление правила с подтверждением diff --git a/backend/Dockerfile b/backend/Dockerfile index e4b3343..23e1969 100755 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,6 +4,7 @@ COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B +RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar FROM eclipse-temurin:17-jre-alpine @@ -13,5 +14,6 @@ USER spring:spring WORKDIR /app COPY --from=build /app/target/app.jar app.jar +COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"] diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index cdcdc0f..0000000 --- a/backend/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Руководство Backend-разработчика Magistr - -Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами. - -Здесь описано, как тут всё устроено, чтобы вы ничего не сломали. - ---- - -## 1. Архитектура мультитенантности - -Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента). - -- **Как приложение понимает, к какой базе обращаться?** - Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`). - В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`. - -- **Как переключаются базы данных?** - Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету. - -> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе. - ---- - -## 2. Динамическое управление тенантами (Kubernetes / ConfigMap) - -Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**. - -Список тенантов не зашит в код: -- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`. -- В классе `DatabaseController` находится API для добавления нового тенанта из админки. -- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`. -- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных. - ---- - -## 3. Базы данных и Миграции (Flyway) - -Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`). -Структурой баз данных правит **Flyway**. - -Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта. - -### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД: - -Если вам нужно добавить новую таблицу, колонку или изменить тип поля: - -1. **Запрещено трогать старые файлы миграций!** - Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`. - -2. **Как правильно добавить таблицу?** - - Зайдите в папку `src/main/resources/db/migration/`. - - Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`. - - Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`). - - Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт. - -3. **Что если локально я накосячил в V2?** - Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**: - `DELETE FROM flyway_schema_history WHERE version = '2';` - Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую. - ---- - -## 4. Как запускать проект локально - -В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру: -```bash -docker compose up -d -``` -Соберется и запустится: -- Фронтенд -- Бэкенд -- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле). - -Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети). -Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway. - -Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище! diff --git a/backend/pom.xml b/backend/pom.xml index 2a160d5..54b385f 100755 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -56,6 +56,13 @@ h2 runtime + + + + io.opentelemetry + opentelemetry-api + 1.49.0 + diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java index 924222b..e85c961 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java @@ -9,6 +9,8 @@ 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. @@ -48,6 +50,8 @@ public class TenantInterceptor implements HandlerInterceptor { // (нужно чтобы админ мог добавить тенант даже если его домен не настроен) 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; } @@ -66,6 +70,8 @@ public class TenantInterceptor implements HandlerInterceptor { } TenantContext.setCurrentTenant(tenant); + MDC.put("tenant.id", tenant); + Span.current().setAttribute("tenant.id", tenant); log.debug("Resolved tenant '{}' from Host '{}'", tenant, host); return true; } @@ -73,6 +79,7 @@ public class TenantInterceptor implements HandlerInterceptor { @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); + MDC.remove("tenant.id"); } private String resolveTenant(String host) { diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 1dfc482..14c7f2a 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -38,14 +38,15 @@ public class AuthController { !passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) { return ResponseEntity .status(401) - .body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null)); + .body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null)); } User user = userOpt.get(); String token = UUID.randomUUID().toString(); String roleName = user.getRole().name(); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); + 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)); } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java new file mode 100644 index 0000000..8adfc3b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java @@ -0,0 +1,106 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.CreateDepartmentRequest; +import com.magistr.app.dto.DepartmentResponse; +import com.magistr.app.model.Department; +import com.magistr.app.repository.DepartmentRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/departments") +public class DepartmentController { + + private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class); + + private final DepartmentRepository departmentRepository; + + public DepartmentController(DepartmentRepository departmentRepository) { + this.departmentRepository = departmentRepository; + } + + @GetMapping + public List getAllDepartments() { + logger.info("Получен запрос на получение списка кафедр"); + try { + List departments = departmentRepository.findAll(); + List response = departments.stream() + .map( d -> new Department( + d.getId(), + d.getDepartmentName(), + d.getDepartmentCode() + )) + .toList(); + logger.info("Получено {} кафедр", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e); + throw e; + } + } + + @PostMapping + public ResponseEntity createDepartment(@RequestBody CreateDepartmentRequest request) { + logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode()); + + try { + if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){ + String errorMessage = "Название кафедры обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) { + String errorMessage = "Кафедра с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) { + String errorMessage = "Код кафедры обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) { + String errorMessage = "Кафедра с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + Department department = new Department(); + department.setDepartmentName(request.getDepartmentName()); + department.setDepartmentCode(request.getDepartmentCode()); + departmentRepository.save(department); + + logger.info("Кафедра успешно создана с ID: {}", department.getId()); + + return ResponseEntity.ok( + new DepartmentResponse( + department.getId(), + department.getDepartmentName(), + department.getDepartmentCode() + ) + ); + } catch (Exception e) { + logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage())); + } + } + + @DeleteMapping("/id") + public ResponseEntity deleteDepartment(@PathVariable Long id) { + logger.info("Получен запрос на удаление кафедры с ID: {}", id); + if (!departmentRepository.existsById(id)) { + logger.info("Кафедра с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + departmentRepository.deleteById(id); + logger.info("Кафедра с ID - {} успешно удалена", id); + return ResponseEntity.ok(Map.of("message", "Кафедра удалена")); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 0365aea..9fd2578 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -6,9 +6,14 @@ import com.magistr.app.model.EducationForm; import com.magistr.app.model.StudentGroup; import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.GroupRepository; +import com.magistr.app.utils.CourseAndSemesterCalculator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Year; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,6 +22,8 @@ import java.util.Optional; @RequestMapping("/api/groups") public class GroupController { + private static final Logger logger = LoggerFactory.getLogger(GroupController.class); + private final GroupRepository groupRepository; private final EducationFormRepository educationFormRepository; @@ -28,56 +35,163 @@ public class GroupController { @GetMapping public List getAllGroups() { - return groupRepository.findAll().stream() - .map(g -> new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName())) - .toList(); + logger.info("Получен запрос на получение всех групп"); + + try { + List groups = groupRepository.findAll(); + + List response = groups.stream() + .map(g -> { + int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); + int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); + return new GroupResponse( + g.getId(), + g.getName(), + g.getGroupSize(), + g.getEducationForm().getId(), + g.getEducationForm().getName(), + g.getDepartmentId(), + course, + semester, + g.getSpecialityCode() + ); + }) + .toList(); + logger.info("Получено {} групп", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e); + throw e; + } + } + + @GetMapping("/{departmentId}") + public ResponseEntity getGroupsByDepartmentId(@PathVariable Long departmentId) { + logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); + try { + List groups = groupRepository.findByDepartmentId(departmentId); + + if(groups.isEmpty()) { + logger.info("Группы для кафедры с ID - {} не найдены", departmentId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Группы для указанной кафедры не найдены"); + } + + List response = groups.stream() + .map(g -> { + int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); + int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); + return new GroupResponse( + g.getId(), + g.getName(), + g.getGroupSize(), + g.getEducationForm().getId(), + g.getEducationForm().getName(), + g.getDepartmentId(), + course, + semester, + g.getSpecialityCode() + ); + }) + .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 public ResponseEntity createGroup(@RequestBody CreateGroupRequest request) { - if (request.getName() == null || request.getName().isBlank()) { - return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно")); - } - if (groupRepository.findByName(request.getName().trim()).isPresent()) { - return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует")); - } - if (request.getGroupSize() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна")); - } - if (request.getEducationFormId() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна")); - } + logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", + request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); + try { + if (request.getName() == null || request.getName().isBlank()) { + String errorMessage = "Название группы обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (groupRepository.findByName(request.getName().trim()).isPresent()) { + String errorMessage = "Группа с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getGroupSize() == null) { + String errorMessage = "Численность группы обязательна"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getEducationFormId() == null) { + String errorMessage = "Форма обучения обязательна"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + String errorMessage = "ID кафедры обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } +// if (request.getCourse() == null || request.getCourse() == 0) { +// String errorMessage = "Курс обязателен"; +// logger.error("Ошибка валидации: {}", errorMessage); +// return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); +// } + if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { + String errorMessage = "Год начала обучения обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) { + String errorMessage = "Код специальности обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } - Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); - if (efOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); + Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); + if (efOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); + } + + StudentGroup group = new StudentGroup(); + group.setName(request.getName().trim()); + group.setGroupSize(request.getGroupSize()); + group.setEducationForm(efOpt.get()); + group.setDepartmentId(request.getDepartmentId()); + group.setYearStartStudy(request.getYearStartStudy()); + group.setSpecialityCode(request.getSpecialityCode()); + groupRepository.save(group); + + logger.info("Группа успешно создана с ID - {}", group.getId()); + + return ResponseEntity.ok(new GroupResponse( + group.getId(), + group.getName(), + group.getGroupSize(), + group.getEducationForm().getId(), + group.getEducationForm().getName(), + group.getDepartmentId(), + group.getYearStartStudy(), + group.getSpecialityCode())); + } catch (Exception e ) { + logger.error("Ошибка при создании группы: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage())); } - - StudentGroup group = new StudentGroup(); - group.setName(request.getName().trim()); - group.setGroupSize(request.getGroupSize()); - group.setEducationForm(efOpt.get()); - groupRepository.save(group); - - return ResponseEntity.ok(new GroupResponse( - group.getId(), - group.getName(), - group.getGroupSize(), - group.getEducationForm().getId(), - group.getEducationForm().getName())); } @DeleteMapping("/{id}") public ResponseEntity deleteGroup(@PathVariable Long id) { + logger.info("Получен запрос на удаление группы с ID - {}", id); if (!groupRepository.existsById(id)) { + logger.info("Группа с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } groupRepository.deleteById(id); + logger.info("Группа с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Группа удалена")); } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java new file mode 100644 index 0000000..65f2994 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java @@ -0,0 +1,283 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.CreateScheduleDataRequest; +import com.magistr.app.dto.ScheduleResponse; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.utils.CourseAndSemesterCalculator; +import com.magistr.app.utils.SemesterTypeValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/department/schedule") +public class ScheduleDataController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class); + + private final ScheduleDataRepository scheduleDataRepository; + private final GroupRepository groupRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SubjectRepository subjectRepository; + private final LessonTypesRepository lessonTypesRepository; + private final UserRepository userRepository; + + public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) { + this.scheduleDataRepository = scheduleDataRepository; + this.groupRepository = groupRepository; + this.specialtiesRepository = specialtiesRepository; + this.subjectRepository = subjectRepository; + this.lessonTypesRepository = lessonTypesRepository; + this.userRepository = userRepository; + } + + @GetMapping("/allList") + public List getAllScheduleDataList() { + logger.info("Получен запрос на получение списка данных расписаний"); + try { + List scheduleData = scheduleDataRepository.findAll(); + List response = scheduleData.stream() + .map(s -> new ScheduleData( + s.getId(), + s.getDepartmentId(), + s.getGroupId(), + s.getSubjectsId(), + s.getLessonTypeId(), + s.getNumberOfHours(), + s.getDivision(), + s.getTeacherId(), + s.getSemesterType(), + s.getPeriod() + )) + .toList(); + logger.info("Получено {} записей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e); + throw e; + } + } + + @GetMapping + public ResponseEntity getSingleScheduleData( + @RequestParam Long departmentId, + @RequestParam SemesterType semesterType, + @RequestParam String period + ) { + logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}", + departmentId, semesterType, period); + try { + List scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period ); + + if(scheduleData.isEmpty()){ + logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period); + return ResponseEntity.ok(Map.of( + "message", "Записей не найдено" + )); + } + + List response = scheduleData.stream() + .map( s -> { + String groupName = groupRepository.findById(s.getGroupId()) + .map(StudentGroup::getName) + .orElse("Неизвестно"); + + int groupSemester = 0; + int groupCourse = 0; + String specialityCode = "Неизвестно"; + + StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null); + + if (group != null) { + groupCourse = CourseAndSemesterCalculator.getFutureCourse(group.getYearStartStudy(), period); + } + + if (group != null) { + groupSemester = CourseAndSemesterCalculator.getFutureSemester(group.getYearStartStudy(), period, semesterType); + } + + if (group != null) { + Long specialityId = group.getSpecialityCode(); + specialityCode = specialtiesRepository.findById(specialityId). + map(Speciality::getSpecialityCode) + .orElse("Неизвестно"); + } + + String subjectName = subjectRepository.findById(s.getSubjectsId()) + .map(Subject::getName) + .orElse("Неизвестно"); + + String lessonType = lessonTypesRepository.findById(s.getLessonTypeId()) + .map(LessonType::getLessonType) + .orElse("Неизвестно"); + + String teacherName = userRepository.findById(s.getTeacherId()) + .map(User::getFullName) + .orElse("Неизвестно"); + + String teacherjobTitle = userRepository.findById(s.getTeacherId()) + .map(User::getJobTitle) + .orElse("Неизвестно"); + + return new ScheduleResponse( + s.getId(), + s.getDepartmentId(), + specialityCode, + groupName, + groupCourse, + groupSemester, + subjectName, + lessonType, + s.getNumberOfHours(), + s.getDivision(), + teacherName, + teacherjobTitle, + s.getSemesterType(), + s.getPeriod()); + } + ) + .toList(); + logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId); + return ResponseEntity.ok(response); + } catch (Exception e) { + logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e); + throw e; + } + } + + // Доделать проверки получаемых полей!!! + @PostMapping("/create") + public ResponseEntity createScheduleData(@RequestBody CreateScheduleDataRequest request) { + logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}", + request.getDepartmentId(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod()); + try { + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + String errorMessage = "ID кафедры обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } else if(!scheduleDataRepository.existsById(request.getDepartmentId())) { + String errorMessage = "Кафедра не найдена"; + logger.info("Кафедра не найдена"); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getGroupId() == null || request.getGroupId() == 0) { + String errorMessage = "ID группы обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getSubjectsId() == null || request.getSubjectsId() == 0) { + String errorMessage = "ID дисциплины обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) { + String errorMessage = "ID типа занятия обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getNumberOfHours() == null) { + request.setNumberOfHours(0L); + } + + if (request.getTeacherId() == null || request.getTeacherId() == 0) { + String errorMessage = "ID преподавателя обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getSemesterType() == null) { + String errorMessage = "Семестр обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) { + String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes(); + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + if (request.getPeriod() == null || request.getPeriod().isBlank()) { + String errorMessage = "Период обязателен"; + logger.info("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( + request.getDepartmentId(), + request.getGroupId(), + request.getSubjectsId(), + request.getLessonTypeId(), + request.getNumberOfHours(), + request.getDivision(), + request.getTeacherId(), + request.getSemesterType(), + request.getPeriod() + ); + + if(existsRecord) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", "Такая запись уже существует")); + } + + ScheduleData scheduleData = new ScheduleData(); + scheduleData.setDepartmentId(request.getDepartmentId()); + scheduleData.setGroupId(request.getGroupId()); + scheduleData.setSubjectsId(request.getSubjectsId()); + scheduleData.setLessonTypeId(request.getLessonTypeId()); + scheduleData.setNumberOfHours(request.getNumberOfHours()); + scheduleData.setDivision(request.getDivision()); + scheduleData.setTeacherId(request.getTeacherId()); + scheduleData.setSemesterType(request.getSemesterType()); + scheduleData.setPeriod(request.getPeriod()); + + ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData); + + Map response = new LinkedHashMap<>(); + response.put("id", savedSchedule.getId()); + response.put("departmentId", savedSchedule.getDepartmentId()); + response.put("groupId", savedSchedule.getGroupId()); + response.put("subjectId", savedSchedule.getSubjectsId()); + response.put("lessonTypeId", savedSchedule.getLessonTypeId()); + response.put("numberOfHours", savedSchedule.getNumberOfHours()); + response.put("isDivision", savedSchedule.getDivision()); + response.put("teacherId", savedSchedule.getTeacherId()); + response.put("semesterType", savedSchedule.getSemesterType()); + response.put("period", savedSchedule.getPeriod()); + + logger.info("Запись успешно создана с ID: {}", savedSchedule.getId()); + + return ResponseEntity.ok(response); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", "Такая запись уже существует")); + } catch (Exception e) { + logger.error("Ошибка при создании записи: {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteById(@PathVariable Long id) { + logger.info("Получен запрос на удаление записи с ID: {}", id); + if(!scheduleDataRepository.existsById(id)) { + logger.info("Запись с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + scheduleDataRepository.deleteById(id); + logger.info("Запись с ID - {} успешно удалена", id); + return ResponseEntity.ok(Map.of("message", "Запись удалена")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java new file mode 100644 index 0000000..92db85a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -0,0 +1,106 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.CreateSpecialityRequest; +import com.magistr.app.dto.SpecialityResponse; +import com.magistr.app.model.Speciality; +import com.magistr.app.repository.SpecialtiesRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/specialties") +public class SpecialityController { + + private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class); + + private final SpecialtiesRepository specialtiesRepository; + + public SpecialityController(SpecialtiesRepository specialtiesRepository) { + this.specialtiesRepository = specialtiesRepository; + } + + @GetMapping + public List getAllSpecialties() { + logger.info("Получен запрос на получение списка специальностей"); + try { + List specialities = specialtiesRepository.findAll(); + List response = specialities.stream() + .map( s -> new Speciality( + s.getId(), + s.getSpecialityName(), + s.getSpecialityCode() + )) + .toList(); + logger.info("Получено {} специальностей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e); + throw e; + } + } + + @PostMapping + public ResponseEntity createSpeciality(@RequestBody CreateSpecialityRequest request) { + logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode()); + + try { + if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) { + String errorMessage = "Название специальности обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) { + String errorMessage = "Специальность с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) { + String errorMessage = "Код специальности обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) { + String errorMessage = "Специальность с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + Speciality speciality = new Speciality(); + speciality.setSpecialityName(request.getSpecialityName()); + speciality.setSpecialityCode(request.getSpecialityCode()); + specialtiesRepository.save(speciality); + + logger.info("Специальность успешно создана с ID: {}", speciality.getId()); + + return ResponseEntity.ok( + new SpecialityResponse( + speciality.getId(), + speciality.getSpecialityName(), + speciality.getSpecialityCode() + ) + ); + } catch (Exception e) { + logger.error("Ошибка при создании специальности: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage())); + } + } + + @DeleteMapping("/id") + public ResponseEntity deleteSpeciality(@PathVariable Long id) { + logger.info("Получен запрос на удаление специальности с ID: {}", id); + if (!specialtiesRepository.existsById(id)) { + logger.info("Специальность с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + specialtiesRepository.deleteById(id); + logger.info("Специальность с ID - {} успешно удалена", id); + return ResponseEntity.ok(Map.of("message", "Специальнсть удалена")); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java index fb9e64f..078c891 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -1,7 +1,13 @@ package com.magistr.app.controller; +import com.magistr.app.dto.CreateSubjectRequest; +import com.magistr.app.dto.SubjectResponse; import com.magistr.app.model.Subject; import com.magistr.app.repository.SubjectRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -12,6 +18,8 @@ import java.util.Map; @RequestMapping("/api/subjects") public class SubjectController { + private static final Logger logger = LoggerFactory.getLogger(SubjectController.class); + private final SubjectRepository subjectRepository; public SubjectController(SubjectRepository subjectRepository) { @@ -20,32 +28,105 @@ public class SubjectController { @GetMapping public List getAllSubjects() { - return subjectRepository.findAll(); + logger.info("Получен запрос на получение всех дисциплин"); + try { + List subjects = subjectRepository.findAll(); + List response = subjects.stream() + .map(s -> new Subject( + s.getId(), + s.getName(), + s.getCode(), + s.getDepartmentId() + )) + .toList(); + logger.info("Получено {} дисциплин", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e); + throw e; + } + } + + @GetMapping("/{departmentId}") + public ResponseEntity getSubjectsByDepartmentId(@PathVariable Long departmentId) { + logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId); + try{ + List subjects = subjectRepository.findByDepartmentId(departmentId); + + if(subjects.isEmpty()){ + logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Дисциплины для указанной кафедры не найдены"); + } + + logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId); + return ResponseEntity.ok(subjects); + } catch (Exception e) { + logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Произошла ошибка при получении списка дисциплин"); + } } @PostMapping - public ResponseEntity createSubject(@RequestBody Map request) { - String name = request.get("name"); - if (name == null || name.isBlank()) { - return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно")); - } - if (subjectRepository.findByName(name.trim()).isPresent()) { - return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует")); - } + public ResponseEntity createSubject(@RequestBody CreateSubjectRequest request) { + logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}", + request.getName(), request.getCode(), request.getDepartmentId()); - Subject subject = new Subject(); - subject.setName(name.trim()); - subjectRepository.save(subject); + try { + if (request.getName() == null || request.getName().isBlank()) { + String errorMessage = "Название обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (subjectRepository.findByName(request.getName().trim()).isPresent()) { + String errorMessage = "Дисциплина с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getCode() == null || request.getCode().isBlank()) { + String errorMessage = "Код дисциплины обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + String errorMessage = "ID кафедры не может быть равен 0 или пустым"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } - return ResponseEntity.ok(subject); + Subject subject = new Subject(); + subject.setName(request.getName()); + subject.setCode(request.getCode()); + subject.setDepartmentId(request.getDepartmentId()); + subjectRepository.save(subject); + + logger.info("Дисциплина успешно создана с ID: {}", subject.getId()); + + return ResponseEntity.ok( + new SubjectResponse( + subject.getId(), + subject.getName(), + subject.getCode(), + subject.getDepartmentId() + ) + ); + } catch (Exception e){ + logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage())); + } } @DeleteMapping("/{id}") public ResponseEntity deleteSubject(@PathVariable Long id) { + logger.info("Получен запрос на удаление дисциплины с ID: {}", id); if (!subjectRepository.existsById(id)) { + logger.info("Дисциплина с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } subjectRepository.deleteById(id); + logger.info("Дисциплина с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); } } diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index 2dfad6b..5f62585 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -2,9 +2,15 @@ package com.magistr.app.controller; import com.magistr.app.dto.CreateUserRequest; import com.magistr.app.dto.UserResponse; +import com.magistr.app.model.Department; import com.magistr.app.model.Role; import com.magistr.app.model.User; +import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.web.bind.annotation.*; @@ -16,62 +22,182 @@ import java.util.Map; @RequestMapping("/api/users") public class UserController { + private static final Logger logger = LoggerFactory.getLogger(UserController.class); private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; private final BCryptPasswordEncoder passwordEncoder; - public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { + public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.departmentRepository = departmentRepository; } @GetMapping public List getAllUsers() { - return userRepository.findAll().stream() - .map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name())) - .toList(); + logger.info("Получен запрос на получение всех пользователей"); + try { + List users = userRepository.findAll(); + + List response = users.stream() + .map(u -> { + String departmentName = departmentRepository.findById(u.getDepartmentId()) + .map(Department::getDepartmentName) + .orElse("Неизвестно"); + + return new UserResponse( + u.getId(), + u.getUsername(), + u.getRole().name(), + u.getFullName(), + u.getJobTitle(), + departmentName); + }) + .toList(); + logger.info("Получено {} пользователей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e); + throw e; + } + } @GetMapping("/teachers") public List getTeachers() { - return userRepository.findByRole(Role.TEACHER).stream() - .map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name())) - .toList(); + logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); + + try { + List users = userRepository.findByRole(Role.TEACHER); + + List response = users.stream() + .map(u -> { + String departmentName = departmentRepository.findById(u.getDepartmentId()) + .map(Department::getDepartmentName) + .orElse("Неизвестно"); + + return new UserResponse( + u.getId(), + u.getUsername(), + u.getRole().name(), + u.getFullName(), + u.getJobTitle(), + departmentName); + }) + .toList(); + logger.info("Получено {} преподавателей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e); + throw e; + } + } + + @GetMapping("/teachers/{departmentId}") + public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ + logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); + try { + List users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId); + + if (users.isEmpty()) { + logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Преподаватели для указанной кафедры не найдены"); + } + + logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); + + List 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 public ResponseEntity createUser(@RequestBody CreateUserRequest request) { - if (request.getUsername() == null || request.getUsername().isBlank()) { - return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно")); - } - if (request.getPassword() == null || request.getPassword().length() < 4) { - return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа")); - } - if (userRepository.findByUsername(request.getUsername()).isPresent()) { - return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует")); - } + logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId()); - Role role; try { - role = Role.valueOf(request.getRole()); - } catch (Exception e) { - return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); + if (request.getUsername() == null || request.getUsername().isBlank()) { + String errorMessage = "Имя пользователя обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getPassword() == null || request.getPassword().length() < 4) { + String errorMessage = "Пароль минимум 4 символа"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + String errorMessage = "Пользователь уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getFullName() == null || request.getFullName().isBlank()) { + String errorMessage = "Имя пользователя обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getJobTitle() == null || request.getJobTitle().isBlank()) { + logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'"); + request.setJobTitle("Не указано"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + String errorMessage = "ID кафедры не может быть равен 0 или пустым"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + Role role; + try { + role = Role.valueOf(request.getRole()); + } catch (Exception e) { + logger.error("Ошибка при преобразовании роли: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setRole(role); + user.setFullName(request.getFullName()); + user.setJobTitle(request.getJobTitle()); + user.setDepartmentId(request.getDepartmentId()); + userRepository.save(user); + + logger.info("Пользователь успешно создан с ID: {}", user.getId()); + + return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId())); + } catch (Exception e ) { + logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage())); } - - User user = new User(); - user.setUsername(request.getUsername()); - user.setPassword(passwordEncoder.encode(request.getPassword())); - user.setRole(role); - userRepository.save(user); - - return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name())); } @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { + logger.info("Получен запрос на удаление пользователя с ID: {}", id); if (!userRepository.existsById(id)) { + logger.info("Пользователь с ID - {} не найден", id); return ResponseEntity.notFound().build(); } userRepository.deleteById(id); + logger.info("Пользователь с ID - {} успешно удалён", id); return ResponseEntity.ok(Map.of("message", "Пользователь удалён")); } } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateDepartmentRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateDepartmentRequest.java new file mode 100644 index 0000000..caa572e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateDepartmentRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index dfbe5a0..107482f 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -5,6 +5,9 @@ public class CreateGroupRequest { private String name; private Long groupSize; private Long educationFormId; + private Long departmentId; + private Integer yearStartStudy; + private Long specialityCode; public String getName() { return name; @@ -29,4 +32,28 @@ public class CreateGroupRequest { public void setEducationFormId(Long educationFormId) { this.educationFormId = educationFormId; } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Integer getYearStartStudy() { + return yearStartStudy; + } + + public void setYearStartStudy(Integer yearStartStudy) { + this.yearStartStudy = yearStartStudy; + } + + public Long getSpecialityCode() { + return specialityCode; + } + + public void setSpecialityCode(Long specialityCode) { + this.specialityCode = specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java new file mode 100644 index 0000000..7160986 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java @@ -0,0 +1,96 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +public class CreateScheduleDataRequest { + private Long id; + private Long departmentId; + private Long groupId; + private Long subjectsId; + private Long lessonTypeId; + private Long numberOfHours; + private Boolean division; + private Long teacherId; + private SemesterType semesterType; + private String period; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public void setSubjectsId(Long subjectsId) { + this.subjectsId = subjectsId; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public void setLessonTypeId(Long lessonTypeId) { + this.lessonTypeId = lessonTypeId; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public void setNumberOfHours(Long numberOfHours) { + this.numberOfHours = numberOfHours; + } + + public Boolean getDivision() { + return division; + } + + public void setDivision(Boolean division) { + this.division = division; + } + + public Long getTeacherId() { + return teacherId; + } + + public void setTeacherId(Long teacherId) { + this.teacherId = teacherId; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public void setSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } + + public String getPeriod() { + return period; + } + + public void setPeriod(String period) { + this.period = period; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateSpecialityRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateSpecialityRequest.java new file mode 100644 index 0000000..9b8aede --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateSpecialityRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateSubjectRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateSubjectRequest.java new file mode 100644 index 0000000..f97d2b0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateSubjectRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java index 1493eaa..0bc9f1e 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java @@ -5,6 +5,9 @@ public class CreateUserRequest { private String username; private String password; private String role; + private String fullName; + private String jobTitle; + private Long departmentId; public CreateUserRequest() { } @@ -32,4 +35,28 @@ public class CreateUserRequest { public void setRole(String role) { this.role = role; } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getJobTitle() { + return jobTitle; + } + + public void setJobTitle(String jobTitle) { + this.jobTitle = jobTitle; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/DepartmentResponse.java b/backend/src/main/java/com/magistr/app/dto/DepartmentResponse.java new file mode 100644 index 0000000..32df4a7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/DepartmentResponse.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 7dd7c58..8e6f174 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -1,5 +1,8 @@ package com.magistr.app.dto; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public class GroupResponse { private Long id; @@ -7,13 +10,33 @@ public class GroupResponse { private Long groupSize; private Long educationFormId; private String educationFormName; + private Long departmentId; + private Integer yearStartStudy; + private Integer course; + private Integer semester; + private Long specialityCode; - 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 course, Integer semester, Long specialityCode) { this.id = id; this.name = name; this.groupSize = groupSize; this.educationFormId = educationFormId; this.educationFormName = educationFormName; + this.departmentId = departmentId; + this.course = course; + this.semester = semester; + this.specialityCode = specialityCode; + } + + public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer yearStartStudy, Long specialityCode) { + this.id = id; + this.name = name; + this.groupSize = groupSize; + this.educationFormId = educationFormId; + this.educationFormName = educationFormName; + this.departmentId = departmentId; + this.yearStartStudy = yearStartStudy; + this.specialityCode = specialityCode; } public Long getId() { @@ -35,4 +58,24 @@ public class GroupResponse { public String getEducationFormName() { return educationFormName; } + + public Long getDepartmentId() { + return departmentId; + } + + public Integer getCourse() { + return course; + } + + public Integer getSemester() { + return semester; + } + + public Integer getYearStartStudy() { + return yearStartStudy; + } + + public Long getSpecialityCode() { + return specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java index 7fa87cd..851619b 100755 --- a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java @@ -7,16 +7,18 @@ public class LoginResponse { private String token; private String role; private String redirect; + private Long departmentId; public LoginResponse() { } - public LoginResponse(boolean success, String message, String token, String role, String redirect) { + public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) { this.success = success; this.message = message; this.token = token; this.role = role; this.redirect = redirect; + this.departmentId = departmentId; } public boolean isSuccess() { @@ -58,4 +60,12 @@ public class LoginResponse { public void setRedirect(String redirect) { this.redirect = redirect; } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java new file mode 100644 index 0000000..36b8f28 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java @@ -0,0 +1,128 @@ +package com.magistr.app.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.magistr.app.model.SemesterType; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ScheduleResponse { + private Long id; + private String specialityCode; + private Long departmentId; + private Long groupId; + private String groupName; + private Integer groupCourse; + private Integer groupSemester; + private Long subjectsId; + private String subjectName; + private Long lessonTypeId; + private String lessonType; + private Long numberOfHours; + private Boolean division; + private Long teacherId; + private String teacherName; + private String teacherJobTitle; + private SemesterType semesterType; + private String period; + + public ScheduleResponse(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.groupId = groupId; + this.subjectsId = subjectsId; + this.lessonTypeId = lessonTypeId; + this.numberOfHours = numberOfHours; + this.division = division; + this.teacherId = teacherId; + this.semesterType = semesterType; + this.period = period; + } + + public ScheduleResponse(Long id, Long departmentId, String specialityCode, String groupName, Integer groupCourse, Integer groupSemester, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.specialityCode = specialityCode; + this.groupName = groupName; + this.groupCourse = groupCourse; + this.groupSemester = groupSemester; + this.subjectName = subjectName; + this.lessonType = lessonType; + this.numberOfHours = numberOfHours; + this.division = division; + this.teacherName = teacherName; + this.teacherJobTitle = teacherJobTitle; + this.semesterType = semesterType; + this.period = period; + } + + public Long getId() { + return id; + } + + public String getSpecialityCode() { + return specialityCode; + } + + public Long getDepartmentId() { + return departmentId; + } + + public Long getGroupId() { + return groupId; + } + + public String getGroupName() { + return groupName; + } + + public Integer getGroupCourse() { + return groupCourse; + } + + public Integer getGroupSemester() { + return groupSemester; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public String getSubjectName() { + return subjectName; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public String getLessonType() { + return lessonType; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public Boolean getDivision() { + return division; + } + + public Long getTeacherId() { + return teacherId; + } + + public String getTeacherName() { + return teacherName; + } + + public String getTeacherJobTitle() { + return teacherJobTitle; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public String getPeriod() { + return period; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/SpecialityResponse.java b/backend/src/main/java/com/magistr/app/dto/SpecialityResponse.java new file mode 100644 index 0000000..49408d2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SpecialityResponse.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/SubjectResponse.java b/backend/src/main/java/com/magistr/app/dto/SubjectResponse.java new file mode 100644 index 0000000..5a9efb0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubjectResponse.java @@ -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; + } + +} diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index b1d3cdb..2bc5ba7 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -1,41 +1,72 @@ package com.magistr.app.dto; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public class UserResponse { private Long id; private String username; private String role; + private String fullName; + private String jobTitle; + private String departmentName; + private Long departmentId; public UserResponse() { } - public UserResponse(Long id, String username, String role) { + public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) { this.id = id; this.username = username; this.role = role; + this.fullName = fullName; + this.jobTitle = jobTitle; + this.departmentName = departmentName; + } + + public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) { + this.id = id; + this.username = username; + this.role = role; + this.fullName = fullName; + this.jobTitle = jobTitle; + this.departmentId = departmentId; + } + + public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) { + this.id = id; + this.role = role; + this.fullName = fullName; + this.jobTitle = jobTitle; + this.departmentId = departmentId; } public Long getId() { return id; } - public void setId(Long id) { - this.id = id; - } - public String getUsername() { return username; } - public void setUsername(String username) { - this.username = username; - } - public String getRole() { return role; } - public void setRole(String role) { - this.role = role; + public String getFullName() { + return fullName; + } + + public String getJobTitle() { + return jobTitle; + } + + public String getDepartmentName() { + return departmentName; + } + + public Long getDepartmentId() { + return departmentId; } } diff --git a/backend/src/main/java/com/magistr/app/model/Department.java b/backend/src/main/java/com/magistr/app/model/Department.java new file mode 100644 index 0000000..d14905e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Department.java @@ -0,0 +1,50 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name="departments") +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false) + private String departmentName; + + @Column(name = "code", nullable = false) + private Long departmentCode; + + public Department() {} + + public Department(Long id, String departmentName, Long departmentCode) { + this.id = id; + this.departmentName = departmentName; + this.departmentCode = departmentCode; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getDepartmentName() { + return departmentName; + } + + public void setDepartmentName(String departmentName) { + this.departmentName = departmentName; + } + + public Long getDepartmentCode() { + return departmentCode; + } + + public void setDepartmentCode(Long departmentCode) { + this.departmentCode = departmentCode; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/LessonType.java b/backend/src/main/java/com/magistr/app/model/LessonType.java new file mode 100644 index 0000000..6eed3ac --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/LessonType.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleData.java b/backend/src/main/java/com/magistr/app/model/ScheduleData.java new file mode 100644 index 0000000..bdd485f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleData.java @@ -0,0 +1,135 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name="schedule_data") +public class ScheduleData { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name="department_id", nullable = false) + private Long departmentId; + + @Column(name="group_id", nullable = false) + private Long groupId; + + @Column(name="subjects_id", nullable = false) + private Long subjectsId; + + @Column(name="lesson_type_id", nullable = false) + private Long lessonTypeId; + + @Column(name="number_of_hours", nullable = false) + private Long numberOfHours; + + @Column(name="is_division", nullable = false) + private Boolean division; + + @Column(name="teacher_id", nullable = false) + private Long teacherId; + + @Enumerated(EnumType.STRING) + @Column(name="semester_type", nullable = false) + private SemesterType semesterType; + + @Column(name="period", nullable = false) + private String period; + + public ScheduleData() {} + + public ScheduleData(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.groupId = groupId; + this.subjectsId = subjectsId; + this.lessonTypeId = lessonTypeId; + this.numberOfHours = numberOfHours; + this.division = division; + this.teacherId = teacherId; + this.semesterType = semesterType; + this.period = period; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public void setSubjectsId(Long subjectsId) { + this.subjectsId = subjectsId; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public void setLessonTypeId(Long lessonTypeId) { + this.lessonTypeId = lessonTypeId; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public void setNumberOfHours(Long numberOfHours) { + this.numberOfHours = numberOfHours; + } + + public Boolean getDivision() { + return division; + } + + public void setDivision(Boolean division) { + this.division = division; + } + + public Long getTeacherId() { + return teacherId; + } + + public void setTeacherId(Long teacherId) { + this.teacherId = teacherId; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public void setSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } + + public String getPeriod() { + return period; + } + + public void setPeriod(String period) { + this.period = period; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/SemesterType.java b/backend/src/main/java/com/magistr/app/model/SemesterType.java new file mode 100644 index 0000000..acf22e3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SemesterType.java @@ -0,0 +1,6 @@ +package com.magistr.app.model; + +public enum SemesterType { + spring, + autumn +} diff --git a/backend/src/main/java/com/magistr/app/model/Speciality.java b/backend/src/main/java/com/magistr/app/model/Speciality.java new file mode 100644 index 0000000..327360b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Speciality.java @@ -0,0 +1,50 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name="specialties") +public class Speciality { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name="name", nullable = false) + private String specialityName; + + @Column(name="specialty_code",nullable = false) + private String specialityCode; + + public Speciality() {} + + public Speciality(Long id, String specialityName, String specialityCode) { + this.id = id; + this.specialityName = specialityName; + this.specialityCode = specialityCode; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSpecialityName() { + return specialityName; + } + + public void setSpecialityName(String specialityName) { + this.specialityName = specialityName; + } + + public String getSpecialityCode() { + return specialityCode; + } + + public void setSpecialityCode(String specialityCode) { + this.specialityCode = specialityCode; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index 9deee86..c0f78fd 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -20,6 +20,15 @@ public class StudentGroup { @JoinColumn(name = "education_form_id", nullable = false) private EducationForm educationForm; + @Column(name = "department_id", nullable = false) + private Long departmentId; + + @Column(name="specialty_code", nullable = false) + private Long specialityCode; + + @Column(name="year_start_study", nullable = false) + private Integer yearStartStudy; + public StudentGroup() { } @@ -54,4 +63,28 @@ public class StudentGroup { public void setEducationForm(EducationForm educationForm) { this.educationForm = educationForm; } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getSpecialityCode() { + return specialityCode; + } + + public void setSpecialityCode(Long specialityCode) { + this.specialityCode = specialityCode; + } + + public Integer getYearStartStudy() { + return yearStartStudy; + } + + public void setYearStartStudy(Integer yearStartStudy) { + this.yearStartStudy = yearStartStudy; + } } diff --git a/backend/src/main/java/com/magistr/app/model/Subject.java b/backend/src/main/java/com/magistr/app/model/Subject.java index c11d0b1..e5e922d 100755 --- a/backend/src/main/java/com/magistr/app/model/Subject.java +++ b/backend/src/main/java/com/magistr/app/model/Subject.java @@ -13,12 +13,20 @@ public class Subject { @Column(unique = true, nullable = false, length = 200) private String name; + @Column(name = "code") + private String code; + + @Column(name = "department_id", nullable = false) + private Long departmentId; + public Subject() { } - public Subject(Long id, String name) { + public Subject(Long id, String name, String code, Long departmentId) { this.id = id; this.name = name; + this.code = code; + this.departmentId = departmentId; } public Long getId() { @@ -36,4 +44,20 @@ public class Subject { 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; + } } diff --git a/backend/src/main/java/com/magistr/app/model/User.java b/backend/src/main/java/com/magistr/app/model/User.java index eb2ba7e..73e41c1 100755 --- a/backend/src/main/java/com/magistr/app/model/User.java +++ b/backend/src/main/java/com/magistr/app/model/User.java @@ -20,6 +20,15 @@ public class User { @Column(nullable = false, length = 20) private Role role = Role.STUDENT; + @Column(name = "full_name", nullable = false) + private String fullName; + + @Column(name="job_title", nullable = false) + private String jobTitle; + + @Column(name="department_id", nullable = false) + private Long departmentId; + public User() { } @@ -54,4 +63,28 @@ public class User { public void setRole(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; + } } diff --git a/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java new file mode 100644 index 0000000..01fc400 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java @@ -0,0 +1,13 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Department; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DepartmentRepository extends JpaRepository { + + Optional findByDepartmentName(String departmentName); + + Optional findByDepartmentCode(Long departmentCode); +} diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index e2fea0f..7ab7312 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -11,4 +11,6 @@ public interface GroupRepository extends JpaRepository { Optional findByName(String name); List findByEducationFormId(Long educationFormId); + + List findByDepartmentId(Long departmentId); } diff --git a/backend/src/main/java/com/magistr/app/repository/LessonTypesRepository.java b/backend/src/main/java/com/magistr/app/repository/LessonTypesRepository.java new file mode 100644 index 0000000..2aade40 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/LessonTypesRepository.java @@ -0,0 +1,7 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.LessonType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LessonTypesRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java new file mode 100644 index 0000000..e9c0318 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java @@ -0,0 +1,24 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleData; +import com.magistr.app.model.SemesterType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ScheduleDataRepository extends JpaRepository { + + List findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period); + + boolean existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( + Long departmentId, + Long groupId, + Long subjectsId, + Long lessonTypeId, + Long numberOfHours, + Boolean division, + Long teacherId, + SemesterType semesterType, + String period + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java new file mode 100644 index 0000000..7c63217 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java @@ -0,0 +1,13 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Speciality; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SpecialtiesRepository extends JpaRepository { + + Optional findBySpecialityName(String specialityName); + + Optional findBySpecialityCode(String specialityCode); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java index 8c5a8fa..69a759f 100755 --- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -3,8 +3,11 @@ package com.magistr.app.repository; import com.magistr.app.model.Subject; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface SubjectRepository extends JpaRepository { Optional findByName(String name); + + List findByDepartmentId(Long departmentId); } diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java index 3711bd3..35932e4 100755 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); List findByRole(Role role); + + List findByRoleAndDepartmentId(Role role, Long departmentId); } diff --git a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java new file mode 100644 index 0000000..af6b488 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java @@ -0,0 +1,49 @@ +package com.magistr.app.utils; + +import com.magistr.app.model.SemesterType; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +public class CourseAndSemesterCalculator { + + public static int getActualCourse(Integer yearStartStudy) { + LocalDate now = LocalDate.now(); + int currentYear = now.getYear(); + int currentMonth = now.getMonthValue(); + + if (currentMonth >= 9) { + return currentYear - yearStartStudy + 1; + } else { + return currentYear - yearStartStudy; + } + } + + public static int getActualSemester(Integer yearStartStudy) { + int course = getActualCourse(yearStartStudy); + int currentMonth = LocalDate.now().getMonthValue(); + + if ( currentMonth <= 1 || currentMonth >= 9) { + return course * 2 - 1; + } else { + return course * 2; + } + } + + public static int getFutureCourse(Integer yearStartStudy, String periodYears) { + int recordYear = Integer.parseInt(periodYears.substring(0, 4)); + return recordYear - yearStartStudy + 1; + } + + public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) { + int course = getFutureCourse(yearStartStudy, periodYears); + + if (semesterType == SemesterType.autumn) { + return course * 2 - 1; + } else if (semesterType == SemesterType.spring) { + return course * 2; + } + throw new IllegalArgumentException("Неизвестный semesterType: " + semesterType); + } +} diff --git a/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java b/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java new file mode 100644 index 0000000..f4b53d7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java @@ -0,0 +1,26 @@ +package com.magistr.app.utils; + +import com.magistr.app.model.SemesterType; + +import java.util.Arrays; + +public class SemesterTypeValidator { + + public static boolean isValidTypeSemester(String semesterType) { + if (semesterType == null) { + return false; + } + try { + SemesterType.valueOf(semesterType); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + public static String getValidTypes() { + return String.join(", ", Arrays.stream(SemesterType.values()) + .map(Enum::name) + .toArray(String[]::new)); + } +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 3c3339b..7416e4d 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -3,6 +3,31 @@ -- ========================================== CREATE EXTENSION IF NOT EXISTS pgcrypto; +-- =============================== +-- Справочники высшего уровня +-- =============================== +CREATE TABLE IF NOT EXISTS departments ( + id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + code BIGINT UNIQUE NOT NULL +); + +INSERT INTO departments (name, code) VALUES + ('Кафедра ИБ', 1), + ('Кафедра ВТ', 2), + ('Кафедра КТ', 3); + +CREATE TABLE IF NOT EXISTS specialties ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + specialty_code VARCHAR(255) NOT NULL +); + +INSERT INTO specialties (name, specialty_code) VALUES + ('Информационная безопасность', '10.03.01'), + ('Информатика и вычислительная техника', '09.03.01'), + ('Программная инженерия', '09.03.04'); + -- ========================================== -- Пользователи и роли -- ========================================== @@ -11,14 +36,17 @@ CREATE TABLE IF NOT EXISTS users ( username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'STUDENT', + full_name VARCHAR(255) NOT NULL, + job_title VARCHAR(255) NOT NULL, + department_id BIGINT NOT NULL REFERENCES departments(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Админ по умолчанию: admin / admin (bcrypt через pgcrypto) -INSERT INTO users (username, password, role) -VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN'), - ('Тестовый преподаватель', '1234567890', 'TEACHER') +INSERT INTO users (username, password, role, full_name, job_title, department_id) +VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1), + ('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2) ON CONFLICT (username) DO NOTHING; -- ========================================== @@ -45,14 +73,16 @@ CREATE TABLE IF NOT EXISTS student_groups ( name VARCHAR(100) UNIQUE NOT NULL, group_size BIGINT NOT NULL, education_form_id BIGINT NOT NULL REFERENCES education_forms(id), - course INT CHECK (course BETWEEN 1 AND 6), + department_id BIGINT NOT NULL REFERENCES departments(id), + specialty_code INT NOT NULL REFERENCES specialties(id), + year_start_study BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Тестовая базовая группа для работы -INSERT INTO student_groups (name, group_size, education_form_id, course) -VALUES ('ИВТ-21-1', 25, 1, 3), - ('ИБ-41м', 15, 2, 2) +INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study) +VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025), + ('ИБ-41м', 15, 2, 1, 1, 2024) ON CONFLICT (name) DO NOTHING; -- ========================================== @@ -75,23 +105,24 @@ CREATE TABLE IF NOT EXISTS subjects ( id BIGSERIAL PRIMARY KEY, name VARCHAR(200) UNIQUE NOT NULL, code VARCHAR(20), + department_id BIGINT NOT NULL REFERENCES departments(id), description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO subjects (name) VALUES - ('Высшая математика'), - ('Философия'), - ('Информатика'), - ('Базы данных'), - ('Английский язык') +INSERT INTO subjects (name, department_id) VALUES + ('Высшая математика', 1), + ('Философия', 1), + ('Информатика', 1), + ('Базы данных', 1), + ('Английский язык', 1) ON CONFLICT (name) DO NOTHING; -- Типы занятий CREATE TABLE IF NOT EXISTS lesson_types ( id BIGSERIAL PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL, - color_code VARCHAR(7) DEFAULT '#3788d8', -- для цветовой индикации в календаре + color_code VARCHAR(7) DEFAULT '#3788d8', duration_minutes INT DEFAULT 90 ); @@ -145,7 +176,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments ( PRIMARY KEY (classroom_id, equipment_id) ); --- Заполнение привязок оборудования с использованием подзапросов INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity) SELECT c.id, e.id, CASE @@ -164,7 +194,6 @@ ON CONFLICT (classroom_id, equipment_id) DO NOTHING; -- Связи для преподавателей -- ========================================== --- Привязка преподавателей к дисциплинам CREATE TABLE IF NOT EXISTS teacher_subjects ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, @@ -173,7 +202,6 @@ CREATE TABLE IF NOT EXISTS teacher_subjects ( PRIMARY KEY(user_id, subject_id) ); --- Какие типы занятий может вести преподаватель по дисциплине CREATE TABLE IF NOT EXISTS teacher_lesson_types ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, @@ -205,6 +233,34 @@ INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesso (2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'), (1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30'); +-- =============================== +-- Создание таблицы данных расписания (schedule_data) +-- =============================== +CREATE TABLE IF NOT EXISTS schedule_data ( + id BIGSERIAL PRIMARY KEY, + department_id BIGINT NOT NULL REFERENCES departments(id), + group_id BIGINT NOT NULL REFERENCES student_groups(id), + subjects_id BIGINT NOT NULL REFERENCES subjects(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + number_of_hours INT NOT NULL, + is_division BOOLEAN NOT NULL DEFAULT FALSE, + teacher_id BIGINT NOT NULL REFERENCES users(id), + semester_type VARCHAR(255) NOT NULL, + period VARCHAR(255) NOT NULL +); + +INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) +VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), + (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'), + (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), + (1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), + (1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), + (1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); + -- ========================================== -- Функция обновления timestamp -- ========================================== @@ -216,7 +272,6 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Триггеры для обновления updated_at CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW @@ -226,4 +281,111 @@ CREATE TRIGGER update_users_updated_at -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; -COMMENT ON TABLE lessons IS 'Основное расписание занятий'; \ No newline at end of file +COMMENT ON TABLE lessons IS 'Основное расписание занятий'; +COMMENT ON TABLE departments IS 'Кафедры'; +COMMENT ON TABLE specialties IS 'Специальности'; +COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; +COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; +COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; +COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; +COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; +COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; +COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; +COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; +COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; +COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; + +COMMENT ON TABLE education_forms IS 'Формы обучения'; +COMMENT ON TABLE subgroups IS 'Подгруппы'; +COMMENT ON TABLE lesson_types IS 'Типы занятий'; +COMMENT ON TABLE equipments IS 'Оборудование'; +COMMENT ON TABLE classrooms IS 'Аудитории'; +COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям'; +COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам'; +COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя'; + +COMMENT ON COLUMN users.id IS 'ID пользователя'; +COMMENT ON COLUMN users.username IS 'Логин пользователя'; +COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя'; +COMMENT ON COLUMN users.role IS 'Роль пользователя'; +COMMENT ON COLUMN users.created_at IS 'Дата и время создания'; +COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления'; +COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; +COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; +COMMENT ON COLUMN users.department_id IS 'ID кафедры'; + +COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; +COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; +COMMENT ON COLUMN education_forms.description IS 'Описание'; +COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN student_groups.id IS 'ID учебной группы'; +COMMENT ON COLUMN student_groups.name IS 'Название группы'; +COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; +COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; +COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; +COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; +COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа'; +COMMENT ON COLUMN subgroups.name IS 'Название подгруппы'; +COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе'; + +COMMENT ON COLUMN subjects.id IS 'ID предмета'; +COMMENT ON COLUMN subjects.name IS 'Название предмета'; +COMMENT ON COLUMN subjects.code IS 'Код предмета'; +COMMENT ON COLUMN subjects.department_id IS 'ID кафедры'; +COMMENT ON COLUMN subjects.description IS 'Описание предмета'; +COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN lesson_types.id IS 'ID урока'; +COMMENT ON COLUMN lesson_types.name IS 'Название типа урока'; +COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока'; +COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах'; + +COMMENT ON COLUMN equipments.id IS 'ID оборудования'; +COMMENT ON COLUMN equipments.name IS 'Название оборудования'; +COMMENT ON COLUMN equipments.description IS 'Описание оборудования'; +COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования'; + +COMMENT ON COLUMN classrooms.id IS 'ID аудитории'; +COMMENT ON COLUMN classrooms.name IS 'Название аудитории'; +COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории'; +COMMENT ON COLUMN classrooms.building IS 'Корпус'; +COMMENT ON COLUMN classrooms.floor IS 'Этаж'; +COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории'; +COMMENT ON COLUMN classrooms.description IS 'Описание аудитории'; +COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; +COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; +COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2 +COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи'; + +COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя'; +COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета'; +COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя'; +COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания'; + +COMMENT ON COLUMN lessons.id IS 'ID урока'; +COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок'; +COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок'; +COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается'; +COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока'; +COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока'; +COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок'; +COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок'; +COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок'; +COMMENT ON COLUMN lessons.time IS 'Время урока'; + +COMMENT ON COLUMN departments.id IS 'ID кафедры'; +COMMENT ON COLUMN departments.name IS 'Название кафедры'; +COMMENT ON COLUMN departments.code IS 'Код кафедры'; + +COMMENT ON COLUMN specialties.id IS 'ID специальности'; +COMMENT ON COLUMN specialties.name IS 'Название специальности'; +COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности'; + +COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; +COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; +COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 0e3481d..a5bd0a0 100755 --- a/compose.yaml +++ b/compose.yaml @@ -8,6 +8,9 @@ services: environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + depends_on: + db: + condition: service_healthy networks: - proxy @@ -32,6 +35,11 @@ services: POSTGRES_USER: myuser POSTGRES_PASSWORD: supersecretpassword POSTGRES_DB: app_db + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ] + interval: 5s + timeout: 5s + retries: 5 networks: - proxy diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..675c31c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,482 @@ +# 🔌 REST API + +Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON. + +--- + +## Аутентификация + +### `POST /api/auth/login` + +Вход в систему. + +**Тело запроса:** +```json +{ + "username": "admin", + "password": "admin" +} +``` + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "OK", + "token": "550e8400-e29b-41d4-a716-446655440000", + "role": "ADMIN", + "redirect": "/admin/" +} +``` + +**Ошибка (401):** +```json +{ + "success": false, + "message": "Неверное имя пользователя или пароль", + "token": null, + "role": null, + "redirect": null +} +``` + +> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` + +--- + +## Пользователи + +### `GET /api/users` + +Список всех пользователей. + +**Ответ:** +```json +[ + { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" }, + { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" } +] +``` + +### `GET /api/users/teachers` + +Список только преподавателей (роль `TEACHER`). + +### `GET /api/users/teachers/{departmentId}` + +Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). + +### `POST /api/users` + +Создание пользователя. + +**Тело запроса:** +```json +{ + "username": "teacher1", + "password": "password", + "role": "TEACHER", + "fullName": "Test Teacher", + "jobTitle": "Proffessor", + "departmentId": 1 +} +``` + +**Валидация:** +- `username` — обязателен и уникален +- `password` — минимум 4 символа +- `role` — `ADMIN`, `TEACHER` или `STUDENT` +- `fullName` — обязателен +- `departmentId` — обязателен + +### `DELETE /api/users/{id}` + +Удаление пользователя. + +--- + +## Расписание (Lessons) + +### `GET /api/users/lessons` + +Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория). + +**Ответ:** +```json +[ + { + "id": 1, + "teacherName": "Тестовый преподаватель", + "groupName": "ИВТ-21-1", + "classroomName": "101 Ленинская", + "educationFormName": "Бакалавриат", + "subjectName": "Высшая математика", + "typeLesson": "Лекция", + "lessonFormat": "Очно", + "day": "Понедельник", + "week": "Верхняя", + "time": "11:40 - 13:10" + } +] +``` + +### `GET /api/users/lessons/{teacherId}` + +Занятия конкретного преподавателя. + +### `POST /api/users/lessons/create` + +Создание занятия. + +**Тело запроса:** +```json +{ + "teacherId": 2, + "groupId": 1, + "subjectId": 1, + "lessonFormat": "Очно", + "typeLesson": "Лекция", + "classroomId": 1, + "day": "Понедельник", + "week": "Верхняя", + "time": "11:40 - 13:10" +} +``` + +**Валидация:** + +| Поле | Правило | +|------|---------| +| `teacherId` | Обязателен, ≠ 0 | +| `groupId` | Обязателен, ≠ 0 | +| `subjectId` | Обязателен, ≠ 0 | +| `lessonFormat` | `Очно` или `Онлайн` | +| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` | +| `classroomId` | Обязателен, ≠ 0 | +| `day` | Пн–Сб (на русском) | +| `week` | `Верхняя`, `Нижняя`, `Обе` | +| `time` | Обязателен | + + +### `PUT /api/users/lessons/update/{lessonId}` + +Обновление занятия. Поддерживает partial update — передаются только изменённые поля. + +**Тело ответа:** +```json +{ + "id": 5, + "teacherId": 1, + "groupId": 1, + "subjectId": 2, + "LessonFormat": "Онлайн", + "typeLesson": "Практическая работа", + "classroomId": 3, + "day": "Понедельник", + "week": "Верхняя", + "time": "9:40 - 11:10", + "updatedFields": { + "teacherId": 1, + "subjectId": 2, + "lessonFormat": "Онлайн", + "classroomId": 3, + "day": "Понедельник", + "time": "9:40 - 11:10" + }, + "message": "Занятие успешно обновлено" +} +``` + +### `DELETE /api/users/lessons/delete/{lessonId}` + +Удаление занятия. + +### `GET /api/users/lessons/ping` + +Проверка доступности контроллера. Возвращает строку `pong`. + +--- + +## Группы + +### `GET /api/groups` + +Список всех групп. + +**Ответ:** +```json +[ + { + "id": 1, + "name": "ИВТ-21-1", + "groupSize": 25, + "educationFormId": 1, + "educationFormName": "Бакалавриат", + "departmentId": 1, + "course": 3, + "specialityCode": 1 + } +] +``` + +### `GET /api/groups/{departmentId}` + +Список всех групп привязанных к конкретной кафедре. + +### `POST /api/groups` + +Создание группы. + +```json +{ + "name": "ИВТ-11", + "groupSize": 12, + "educationFormId": 1, + "departmentId": 1, + "course": 2, + "specialityCode": 1 +} +``` + +### `DELETE /api/groups/{id}` + +Удаление группы. + +--- + +## Аудитории + +### `GET /api/classrooms` + +Список аудиторий с привязанным оборудованием. + +**Ответ:** +```json +[ + { + "id": 1, + "name": "101 Ленинская", + "capacity": 120, + "isAvailable": true, + "equipments": [ + { "id": 1, "name": "Проектор" }, + { "id": 4, "name": "Интерактивная доска" } + ] + } +] +``` + +### `POST /api/classrooms` + +Создание аудитории. + +```json +{ + "name": "404 Лаборатория", + "capacity": 30, + "isAvailable": true, + "equipmentIds": [1, 2, 3] +} +``` + +### `PUT /api/classrooms/{id}` + +Обновление аудитории (partial update). + +### `DELETE /api/classrooms/{id}` + +Удаление аудитории. + +--- + +## Дисциплины + +### `GET /api/subjects` + +Список всех дисциплин. + +```json +{ + "name": "Физика", + "code": null, + "departmentId": 1 +} +``` + +### `GET /api/subjects/{departmentId}` + +Список всех дисциплин привязанных к кафедре. + +### `POST /api/subjects` + +```json +{ + "name": "Физика", + "code": null, + "departmentId": 1 +} +``` + +### `DELETE /api/subjects/{id}` + +Удаление дисциплины. + +--- + +## Оборудование + +### `GET /api/equipments` + +Список всего оборудования. + +### `POST /api/equipments` + +```json +{ "name": "3D-принтер" } +``` + +### `DELETE /api/equipments/{id}` + +Удаление оборудования. + +--- + +## Формы обучения + +### `GET /api/education-forms` + +Список форм обучения. + +**Ответ:** +```json +[ + { "id": 1, "name": "Бакалавриат" }, + { "id": 2, "name": "Магистратура" } +] +``` + +### `POST /api/education-forms` + +```json +{ "name": "Аспирантура" } +``` + +### `DELETE /api/education-forms/{id}` + +Удаление формы обучения. **Невозможно**, если к ней привязаны группы. + +--- + +## Привязка «Преподаватель ↔ Дисциплина» + +### `GET /api/teacher-subjects` + +Список всех привязок. + +**Ответ:** +```json +[ + { + "userId": 2, + "userName": "Тестовый преподаватель", + "subjectId": 1, + "subjectName": "Высшая математика" + } +] +``` + +### `POST /api/teacher-subjects` + +```json +{ + "userId": 2, + "subjectId": 3 +} +``` + +### `DELETE /api/teacher-subjects` + +```json +{ + "userId": 2, + "subjectId": 3 +} +``` + +--- + +## Управление тенантами (Базы данных) + +### `GET /api/database/status` + +Статус текущего подключения (определяется по домену запроса). + +**Ответ:** +```json +{ + "tenant": "default", + "connected": true, + "configured": true, + "name": "Default", + "url": "jdbc:postgresql://db:5432/app_db" +} +``` + +### `GET /api/database/tenants` + +Список всех тенантов. + +### `POST /api/database/tenants` + +Добавление нового тенанта. + +```json +{ + "name": "СВФУ", + "domain": "swsu", + "url": "jdbc:postgresql://db-host:5432/swsu_db", + "username": "dbuser", + "password": "dbpass" +} +``` + +**Логика:** +1. Создаёт HikariCP пул для нового тенанта +2. Запускает Flyway миграции на его БД +3. Обновляет Kubernetes ConfigMap + +### `DELETE /api/database/tenants/{domain}` + +Удаление тенанта. + +### `POST /api/database/test` + +Тест подключения к произвольной БД (без регистрации тенанта). + +```json +{ + "url": "jdbc:postgresql://host:5432/testdb", + "username": "user", + "password": "pass" +} +``` + +**Ответ:** +```json +{ + "success": true, + "message": "Подключение успешно!" +} +``` + +--- + +## Коды ответов + +| Код | Описание | +|-----|----------| +| `200` | Успех | +| `400` | Ошибка валидации (с `message` в теле) | +| `401` | Неверные учётные данные | +| `404` | Ресурс / тенант не найден | +| `500` | Внутренняя ошибка сервера | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f4e3cf9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,142 @@ +# 🏗 Архитектура системы + +## Общая схема + +```mermaid +graph TD + Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"] + Caddy -->|:80| Frontend["Frontend
(Apache httpd:alpine)"] + Caddy -->|/api/*| Backend["Backend
(Spring Boot 3.2.5)"] + + Backend --> TenantRouter{"TenantRoutingDataSource"} + TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL
swsu_db"] + TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL
mgu_db"] + TenantRouter -->|...| DBn["PostgreSQL
tenant_n_db"] + + Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"] + OTel --> SigNoz["SigNoz"] +``` + +## Компоненты + +### Frontend (Apache httpd:alpine) +- **Тип:** Статические файлы (HTML/CSS/JS) +- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server +- **Порт:** 80 +- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/` +- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`) + +### Backend (Spring Boot 3.2.5) +- **Тип:** REST API сервер +- **Язык:** Java 17 +- **Порт:** 8080 (внутренний) +- **ORM:** Hibernate (JPA), `ddl-auto=none` +- **Миграции:** Flyway (программный запуск при подключении тенанта) +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены + +### PostgreSQL +- **Версия:** `postgres:alpine3.23` +- **Локально:** Одна БД `app_db` (тенант `default`) +- **Продакшн:** Множество БД, по одной на каждый университет (тенант) + +### Caddy (реверс-прокси) +- **Расположение:** `../caddy-proxy/` +- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend +- **Домен:** `*.zuev.company` + +--- + +## Мультитенантная архитектура + +Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL. + +### Принцип работы + +```mermaid +sequenceDiagram + participant Browser as Браузер + participant Interceptor as TenantInterceptor + participant Context as TenantContext + participant Router as TenantRoutingDataSource + participant DB as PostgreSQL + + Browser->>Interceptor: GET /api/users
Host: swsu.zuev.company + Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu" + Interceptor->>Context: setCurrentTenant("swsu") + Note over Interceptor: Проверка: hasTenant("swsu")? + Interceptor-->>Browser: 404 если тенант не найден + + Note over Context,Router: Обработка запроса контроллером + Router->>Router: determineCurrentLookupKey() → "swsu" + Router->>DB: SQL запрос к swsu_db + DB-->>Browser: Ответ с данными +``` + +### Ключевые классы + +| Класс | Назначение | +|-------|-----------| +| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант | +| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | +| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | +| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | +| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | +| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API | +| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | + +### Определение тенанта + +Логика определения тенанта по заголовку `Host`: + +| Host | Результат | +|------|----------| +| `swsu.zuev.company` | `swsu` | +| `mgu.zuev.company` | `mgu` | +| `localhost` | `default` | +| `localhost:8080` | `default` | +| `192.168.1.1` | `default` | + +### Конфигурация тенантов + +Список тенантов хранится в JSON-файле: +- **Локально:** `backend/tenants.json` +- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json` + +Формат: +```json +[ + { + "name": "ЮЗГУ", + "domain": "swsu", + "url": "jdbc:postgresql://db-host:5432/swsu_db", + "username": "dbuser", + "password": "dbpass" + } +] +``` + +### Жизненный цикл тенанта + +1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap +2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты +3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap + +### Fallback при отсутствии тенантов + +Если при запуске нет ни одного настроенного тенанта: +1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` +2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA + +--- + +## Аутентификация + +Система использует **простую модель аутентификации** без JWT или Spring Security фильтров: + +1. Клиент отправляет `POST /api/auth/login` с `username` и `password` +2. Backend проверяет пароль через `BCryptPasswordEncoder` +3. При успехе возвращается: + - UUID-токен (для заголовка `Authorization: Bearer`) + - Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`) + - Redirect URL (`/admin/`, `/teacher/`, `/student/`) +4. Токен хранится в `localStorage` на клиенте diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md new file mode 100644 index 0000000..08cf64e --- /dev/null +++ b/docs/BUSINESS_LOGIC.md @@ -0,0 +1,149 @@ +# 📋 Бизнес-логика + +## Ролевая модель + +Система поддерживает три роли пользователей: + +| Роль | Enum | Возможности | +|------|------|------------| +| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). | +| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | +| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | + +После авторизации пользователь перенаправляется на свой интерфейс: +- `ADMIN` → `/admin/` +- `TEACHER` → `/teacher/` +- `STUDENT` → `/student/` + +--- + +## Управление ресурсами + +### Кафедры (Departments) + +Организационные единицы университета. К кафедре привязываются пользователи, группы и дисциплины. + +- Имеют уникальный числовой `code` +- Предзаполнены: «Кафедра ИБ», «Кафедра ВТ», «Кафедра КТ» + +### Специальности (Specialties) + +Учебные направления с кодом по ФГОС. + +- Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04) + +### Формы обучения (Education Forms) + +Уровни/формы обучения для привязки к группам. + +- Предзаполнены: Бакалавриат, Магистратура, Специалитет +- Нельзя удалить форму обучения, если к ней привязаны группы + +### Учебные группы (Student Groups) + +- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6) +- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) + +### Аудитории (Classrooms) + +- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность +- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества +- **Статус:** Флаг `is_available` для блокирования назначения пар + +### Оборудование (Equipments) + +Каталог оборудования для привязки к аудиториям. + +- Предзаполнены: Проектор, ПК, Лаборатория, Интерактивная доска, Документ-камера, Аудиосистема +- Уникальность по названию + +### Дисциплины (Subjects) + +- **Поля:** Название (уникальное), код, кафедра, описание +- Привязка преподавателей через `teacher_subjects` (Many-to-Many) + +--- + +## Логика расписания + +### Сущность «Занятие» (Lesson) + +Каждая запись в расписании содержит: + +| Поле | Описание | Пример | +|------|----------|--------| +| `teacher_id` | Преподаватель | 2 | +| `group_id` | Учебная группа | 1 | +| `subject_id` | Дисциплина | 3 | +| `lesson_format` | Формат проведения | `Очно`, `Онлайн` | +| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` | +| `classroom_id` | Аудитория | 1 | +| `day` | День недели | `Понедельник` ... `Суббота` | +| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` | +| `time` | Временной слот | `8:00 - 9:30` | + +### Временны́е слоты + +Система использует 7 фиксированных слотов по 90 минут: + +| № | Время | +|---|-------| +| 1 | 08:00 – 09:30 | +| 2 | 09:40 – 11:10 | +| 3 | 11:40 – 13:10 | +| 4 | 13:30 – 15:00 | +| 5 | 15:00 – 16:30 | +| 6 | 16:40 – 18:10 | +| 7 | 18:30 – 20:00 | + +### Валидация при создании/обновлении + +- **Дни:** только `Понедельник` – `Суббота` (`DayAndWeekValidator`) +- **Недели:** только `Верхняя`, `Нижняя`, `Обе` +- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`) +- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` +- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 + +### Данные к составлению расписания (Schedule Data) + +Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания: + +| Поле | Описание | +|------|----------| +| `department_id` | Кафедра | +| `semester` | Номер семестра | +| `group_id` | Учебная группа | +| `subjects_id` | Дисциплина | +| `lesson_type_id` | Тип занятия | +| `number_of_hours` | Количество часов | +| `is_division` | Деление на подгруппы | +| `teacher_id` | Преподаватель | +| `semester_type` | Тип семестра (Весенний / Осенний) | +| `period` | Учебный год (напр. `2024/2025`) | + +--- + +## Привязка преподаватель ↔ дисциплина + +Связь Many-to-Many через таблицу `teacher_subjects`: +- Указывается, какие дисциплины может вести конкретный преподаватель +- Дополнительные поля: `qualification_level`, `experience_years` + +Дополнительная связь через `teacher_lesson_types`: +- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине + +--- + +## Бизнес-правила (планируемые) + +> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде. + +### Проверка конфликтов +- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях +- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории +- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории + +### Управление инцидентами +- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода +- Автоматическая подсветка конфликтующих пар (Red Zone) +- Resolution Wizard: предложение замены преподавателя или переноса занятия diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..c025547 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,362 @@ +# 🗄 База данных + +## Общая информация + +- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL) +- **Управление схемой:** Flyway (программный запуск) +- **Hibernate DDL:** Отключён (`ddl-auto=none`) +- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей) +- **Мультитенантность:** Каждый тенант = отдельная БД + +--- + +## ER-диаграмма + +```mermaid +erDiagram + departments { + BIGSERIAL id PK + VARCHAR name + BIGINT code UK + } + + specialties { + BIGSERIAL id PK + VARCHAR name + VARCHAR specialty_code + } + + users { + BIGSERIAL id PK + VARCHAR username UK + VARCHAR password + VARCHAR role + VARCHAR full_name + VARCHAR job_title + BIGINT department_id FK + TIMESTAMP created_at + TIMESTAMP updated_at + } + + education_forms { + BIGSERIAL id PK + VARCHAR name UK + TEXT description + TIMESTAMP created_at + } + + student_groups { + BIGSERIAL id PK + VARCHAR name UK + BIGINT group_size + BIGINT education_form_id FK + BIGINT department_id FK + INT course + TIMESTAMP created_at + } + + subgroups { + BIGSERIAL id PK + BIGINT group_id FK + VARCHAR name + INT student_capacity + } + + subjects { + BIGSERIAL id PK + VARCHAR name UK + VARCHAR code + BIGINT department_id FK + TEXT description + TIMESTAMP created_at + } + + lesson_types { + BIGSERIAL id PK + VARCHAR name UK + VARCHAR color_code + INT duration_minutes + } + + equipments { + BIGSERIAL id PK + VARCHAR name UK + TEXT description + VARCHAR inventory_number + } + + classrooms { + BIGSERIAL id PK + VARCHAR name UK + INT capacity + VARCHAR building + INT floor + BOOLEAN is_available + TEXT description + TIMESTAMP created_at + } + + classroom_equipments { + BIGINT classroom_id FK,PK + BIGINT equipment_id FK,PK + INT quantity + TEXT notes + } + + teacher_subjects { + BIGINT user_id FK,PK + BIGINT subject_id FK,PK + VARCHAR qualification_level + INT experience_years + } + + teacher_lesson_types { + BIGINT user_id FK,PK + BIGINT subject_id FK,PK + BIGINT lesson_type_id FK,PK + } + + lessons { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT group_id FK + BIGINT subject_id FK + VARCHAR lesson_format + VARCHAR type_lesson + BIGINT classroom_id FK + VARCHAR day + VARCHAR week + VARCHAR time + } + + schedule_data { + BIGSERIAL id PK + BIGINT department_id FK + INT semester + BIGINT group_id FK + BIGINT subjects_id FK + BIGINT lesson_type_id FK + INT number_of_hours + BOOLEAN is_division + BIGINT teacher_id FK + VARCHAR semester_type + VARCHAR period + } + + departments ||--o{ users : "department_id" + departments ||--o{ student_groups : "department_id" + departments ||--o{ subjects : "department_id" + departments ||--o{ schedule_data : "department_id" + education_forms ||--o{ student_groups : "education_form_id" + student_groups ||--o{ subgroups : "group_id" + student_groups ||--o{ lessons : "group_id" + student_groups ||--o{ schedule_data : "group_id" + users ||--o{ lessons : "teacher_id" + users ||--o{ teacher_subjects : "user_id" + users ||--o{ teacher_lesson_types : "user_id" + users ||--o{ schedule_data : "teacher_id" + subjects ||--o{ lessons : "subject_id" + subjects ||--o{ teacher_subjects : "subject_id" + subjects ||--o{ teacher_lesson_types : "subject_id" + subjects ||--o{ schedule_data : "subjects_id" + lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" + lesson_types ||--o{ schedule_data : "lesson_type_id" + classrooms ||--o{ lessons : "classroom_id" + classrooms ||--o{ classroom_equipments : "classroom_id" + equipments ||--o{ classroom_equipments : "equipment_id" +``` + +--- + +## Описание таблиц + +### Справочники высшего уровня + +#### `departments` — Кафедры +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID кафедры | +| `name` | VARCHAR(255) | Название кафедры | +| `code` | BIGINT UNIQUE | Код кафедры | + +#### `specialties` — Специальности +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID специальности | +| `name` | VARCHAR(255) | Название специальности | +| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) | + +### Пользователи + +#### `users` — Пользователи системы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID пользователя | +| `username` | VARCHAR(50) UNIQUE | Логин | +| `password` | VARCHAR(255) | bcrypt-хеш пароля | +| `role` | VARCHAR(20) | `ADMIN`, `TEACHER`, `STUDENT` | +| `full_name` | VARCHAR(255) | ФИО | +| `job_title` | VARCHAR(255) | Должность | +| `department_id` | BIGINT FK → departments | Кафедра | +| `created_at` | TIMESTAMP | Дата создания | +| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) | + +> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. + +### Учебный процесс + +#### `education_forms` — Формы обучения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) | +| `description` | TEXT | Описание | + +#### `student_groups` — Учебные группы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) | +| `group_size` | BIGINT | Количество студентов | +| `education_form_id` | BIGINT FK → education_forms | Форма обучения | +| `department_id` | BIGINT FK → departments | Кафедра | +| `course` | INT CHECK(1–6) | Курс | + +#### `subgroups` — Подгруппы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа | +| `name` | VARCHAR(100) | Название подгруппы | +| `student_capacity` | INT | Количество студентов | + +#### `subjects` — Дисциплины +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(200) UNIQUE | Название | +| `code` | VARCHAR(20) | Код предмета | +| `department_id` | BIGINT FK → departments | Кафедра | +| `description` | TEXT | Описание | + +### Аудиторный фонд + +#### `classrooms` — Аудитории +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название (напр. `101 Ленинская`) | +| `capacity` | INT CHECK(> 0) | Вместимость | +| `building` | VARCHAR(50) | Корпус | +| `floor` | INT | Этаж | +| `is_available` | BOOLEAN | Доступна для назначения пар | +| `description` | TEXT | Описание | + +#### `equipments` — Оборудование +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название | +| `description` | TEXT | Описание | +| `inventory_number` | VARCHAR(50) | Инвентарный номер | + +#### `classroom_equipments` — Привязка оборудования к аудиториям +| Колонка | Тип | Описание | +|---------|-----|----------| +| `classroom_id` | BIGINT PK, FK → classrooms (CASCADE) | Аудитория | +| `equipment_id` | BIGINT PK, FK → equipments (CASCADE) | Оборудование | +| `quantity` | INT CHECK(> 0) | Количество единиц | +| `notes` | TEXT | Примечания | + +### Расписание + +#### `lessons` — Основное расписание занятий +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `group_id` | BIGINT FK → student_groups | Группа | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` | +| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` | +| `classroom_id` | BIGINT FK → classrooms | Аудитория | +| `day` | VARCHAR(255) | День недели | +| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` | +| `time` | VARCHAR(255) | Временной слот | + +#### `lesson_types` — Типы занятий (справочник) +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название типа | +| `color_code` | VARCHAR(7) | HEX-цвет для UI (напр. `#FF6B6B`) | +| `duration_minutes` | INT | Длительность (по умолчанию 90) | + +### Связи «Преподаватель ↔ Дисциплина» + +#### `teacher_subjects` — Квалификация преподавателей +| Колонка | Тип | Описание | +|---------|-----|----------| +| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель | +| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | +| `qualification_level` | VARCHAR(50) | Уровень квалификации | +| `experience_years` | INT | Стаж | + +#### `teacher_lesson_types` — Типы занятий преподавателя +| Колонка | Тип | Описание | +|---------|-----|----------| +| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель | +| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | +| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | + +#### `schedule_data` — Данные к составлению расписания +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `department_id` | BIGINT FK → departments | Кафедра | +| `semester` | INT | Номер семестра | +| `group_id` | BIGINT FK → student_groups | Группа | +| `subjects_id` | BIGINT FK → subjects | Дисциплина | +| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | +| `number_of_hours` | INT | Количество часов | +| `is_division` | BOOLEAN | Деление на подгруппы | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `semester_type` | VARCHAR(255) | Весенний / Осенний | +| `period` | VARCHAR(255) | Учебный год | + +--- + +## Flyway миграции + +### Правила работы + +1. Все миграции находятся в `backend/src/main/resources/db/migration/` +2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) +3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway +4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) +5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline + +### Текущие миграции + +| Файл | Описание | +|------|----------| +| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | + +### Накатывание на существующих тенантов + +Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend: + +```bash +# Kubernetes +kubectl rollout restart deployment backend -n magistr + +# Docker Compose (локально) +docker compose restart backend +``` + +### Полный сброс БД (локально) + +```bash +docker compose down -v # Удаляет volumes (данные) +docker compose up -d # Пересоздаёт БД с нуля +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..a0ba3a2 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,275 @@ +# 🛠 Руководство для разработчиков + +## Локальный запуск + +### Предварительные требования + +- Docker и Docker Compose +- Git +- (Опционально) Java 17 + Maven 3.9+ для запуска backend вне Docker + +### Первый запуск + +```bash +# Создать Docker-сеть +docker network create proxy + +# Собрать и запустить +docker compose up -d --build + +# Убедиться, что всё работает +docker compose logs -f +``` + +Приложение доступно: **http://localhost:80** + +### Пересборка после изменений + +```bash +# Пересобрать только backend +docker compose up -d --build backend + +# Пересобрать только frontend +docker compose up -d --build frontend +``` + +### Полный сброс данных + +```bash +docker compose down -v # Удаляет БД +docker compose up -d # Пересоздаёт с нуля +``` + +--- + +## Соглашения о коде + +### Java (Backend) + +#### Именование + +| Категория | Стиль | Пример | +|-----------|-------|--------| +| Классы | PascalCase | `LessonsController`, `LessonResponse` | +| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` | +| Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` | +| Пакеты | lowercase | `com.magistr.app.controller` | + +#### Архитектурные правила + +- **Constructor Injection** — все зависимости через конструктор (не `@Autowired` на поля) +- **Controller → Repository** — контроллеры работают напрямую с репозиториями (без слоя service) +- **Префикс `/api/`** — все REST-эндпоинты +- **`ResponseEntity`** — все мутирующие методы возвращают `ResponseEntity` с HTTP-статусом +- **Сообщения на русском** — все ошибки и уведомления на русском языке + +#### Логирование + +Используйте SLF4J: + +```java +private static final Logger logger = LoggerFactory.getLogger(MyController.class); + +// Информационные сообщения +logger.info("Запрос на получение всех занятий"); + +// Ошибки с полным стектрейсом +logger.error("Ошибка при сохранении: {}", e.getMessage(), e); +``` + +#### Валидация + +- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`) +- Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()` + +#### Импорты + +```java +// 1. Static imports +import static org.junit.Assert.*; + +// 2. Java/Jakarta +import java.util.*; +import jakarta.persistence.*; + +// 3. External libraries +import org.springframework.web.bind.annotation.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +// 4. Internal packages (wildcard для того же модуля) +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +``` + +#### Форматирование + +- **Отступы:** 4 пробела +- **Скобки:** K&R style (открывающая на той же строке) +- **Длина строки:** до 120 символов +- **Фигурные скобки** обязательны для `if`/`for`/`while` + +### JavaScript (Frontend) + +#### Именование + +| Категория | Стиль | Пример | +|-----------|-------|--------| +| Файлы | kebab-case | `main.js`, `schedule-view.js` | +| Функции и переменные | camelCase | `loadUsers()`, `pageTitle` | +| Константы | UPPER_SNAKE_CASE | `API_BASE_URL` | + +#### Модули + +- ES6 Modules с `import`/`export` +- **Всегда указывать расширение:** `import { api } from './api.js';` + +#### Лучшие практики + +```javascript +// ✅ Предпочитайте const +const token = localStorage.getItem('token'); + +// ✅ Async/await вместо .then() +async function loadData() { + try { + const data = await api.get('/api/users'); + } catch (e) { + console.error('Ошибка:', e.message); + } +} + +// ✅ Template literals +const msg = `Найдено ${items.length} записей`; + +// ✅ Деструктуризация +const { id, name, role } = user; +``` + +#### Форматирование + +- **Отступы:** 4 пробела +- **Кавычки:** одинарные `'` +- **Точки с запятой:** обязательны + +--- + +## Создание нового эндпоинта (пошагово) + +### 1. Модель (если нужна новая таблица) + +Создайте Flyway миграцию `V{N}__{description}.sql`: + +```sql +-- backend/src/main/resources/db/migration/V3__add_absences.sql +CREATE TABLE IF NOT EXISTS absences ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + reason VARCHAR(255) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL +); +``` + +Создайте JPA-сущность: + +```java +@Entity +@Table(name = "absences") +public class Absence { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // ... +} +``` + +### 2. Репозиторий + +```java +public interface AbsenceRepository extends JpaRepository { + List findByTeacherId(Long teacherId); +} +``` + +### 3. DTO (опционально) + +```java +public record AbsenceResponse(Long id, String teacherName, String reason) {} +``` + +### 4. Контроллер + +```java +@RestController +@RequestMapping("/api/absences") +public class AbsenceController { + private final AbsenceRepository absenceRepository; + + public AbsenceController(AbsenceRepository absenceRepository) { + this.absenceRepository = absenceRepository; + } + + @GetMapping + public List getAll() { + return absenceRepository.findAll(); + } +} +``` + +--- + +## Работа с миграциями Flyway + +### Правила + +1. **Никогда** не изменяйте уже закоммиченные файлы миграций +2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) +3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... +4. После добавления — перезапустите backend для применения + +### Применение + +```bash +# Локально — сброс и повтор всех миграций +docker compose down -v && docker compose up -d + +# Продакшн — применить к существующим тенантам +kubectl rollout restart deployment backend -n magistr +``` + +--- + +## Структура пакетов (Backend) + +``` +com.magistr.app/ +├── Application.java # Точка входа +├── config/ +│ ├── AppConfig.java # Бины (BCryptPasswordEncoder) +│ ├── DataInitializer.java # Инициализация данных +│ └── tenant/ # Мультитенантность +│ ├── TenantConfig.java # POJO конфигурации тенанта +│ ├── TenantContext.java # ThreadLocal текущего тенанта +│ ├── TenantInterceptor.java # Определение тенанта из Host +│ ├── TenantRoutingDataSource.java # Маршрутизация к БД +│ ├── TenantDataSourceConfig.java # Spring-конфигурация +│ ├── TenantConfigWatcher.java # Периодическая синхронизация +│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap +├── controller/ # REST-контроллеры +│ ├── AuthController.java +│ ├── LessonsController.java +│ ├── ClassroomController.java +│ ├── DatabaseController.java +│ ├── UserController.java +│ ├── GroupController.java +│ ├── SubjectController.java +│ ├── EquipmentController.java +│ ├── EducationFormController.java +│ └── TeacherSubjectController.java +├── dto/ # Data Transfer Objects +├── model/ # JPA-сущности +├── repository/ # Spring Data JPA +└── utils/ # Валидаторы + ├── DayAndWeekValidator.java + └── TypeAndFormatLessonValidator.java +``` diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..2a224ca --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,256 @@ +# 🎨 Frontend + +## Общая информация + +| Параметр | Значение | +|----------|----------| +| **Фреймворк** | Нет (Vanilla JavaScript) | +| **Модульная система** | ES6 Modules (`import`/`export`) | +| **Стили** | CSS (модульный подход) | +| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) | +| **Веб-сервер** | Apache httpd:alpine | + +--- + +## Структура файлов + +``` +frontend/ +├── index.html # 🔐 Страница авторизации (общая) +├── script.js # Логика авторизации +├── style.css # Стили страницы авторизации +├── theme-toggle.js # Переключение светлой/тёмной темы +├── Dockerfile # httpd:alpine +│ +├── admin/ # 👨‍💼 Интерфейс администратора +│ ├── index.html # SPA-оболочка с sidebar +│ ├── css/ +│ │ ├── main.css # CSS-переменные, цвета, типографика +│ │ ├── layout.css # Раскладка (sidebar, topbar, content) +│ │ ├── components.css # Кнопки, таблицы, карточки, формы +│ │ ├── modals.css # Модальные окна +│ │ ├── department.css # Стили кафедры +│ │ └── departments-data.css # Стили создания кафедры/специальности +│ ├── js/ +│ │ ├── main.js # Инициализация, маршрутизация, навигация +│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) +│ │ ├── utils.js # Утилиты +│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) +│ │ └── views/ # Модули представлений +│ │ ├── users.js # Управление пользователями +│ │ ├── groups.js # Управление группами +│ │ ├── classrooms.js # Управление аудиториями +│ │ ├── subjects.js # Управление дисциплинами +│ │ ├── equipments.js # Управление оборудованием +│ │ ├── edu-forms.js # Формы обучения +│ │ ├── schedule.js # Расписание занятий +│ │ ├── database.js # Управление тенантами +│ │ ├── department.js # Кафедры +│ │ └── departments-data.js # Создание кафедры/специальности +│ ├── views/ # HTML-шаблоны представлений +│ │ ├── users.html +│ │ ├── groups.html +│ │ ├── classrooms.html +│ │ ├── subjects.html +│ │ ├── equipments.html +│ │ ├── edu-forms.html +│ │ ├── schedule.html +│ │ ├── database.html +│ │ ├── department.html +│ │ └── departments-data.html +│ │ +│ └── settings/ # ⚙️ Страница настроек (отдельный SPA) +│ ├── index.html # Оболочка с собственной sidebar +│ ├── css/ +│ │ ├── main.css # CSS-переменные, базовые стили +│ │ └── layout.css # Sidebar, topbar, content +│ ├── js/ +│ │ └── main.js # Навигация по вкладкам настроек +│ └── views/ +│ └── general.html # Общие настройки (заглушка) +│ +├── teacher/ # 👩‍🏫 Интерфейс преподавателя +│ └── index.html # Просмотр расписания +│ +└── student/ # 🎓 Интерфейс студента + └── index.html # Просмотр расписания (read-only) +``` + +--- + +## Система маршрутизации (Admin SPA) + +Админ-панель работает как **Single Page Application** без фреймворка. + +Навигация реализована через `data-tab` атрибуты на элементах sidebar: + +```html +Пользователи +Группы +Расписание занятий +``` + +При клике на пункт меню `main.js`: +1. Загружает HTML-шаблон из `views/{tab}.html` через `fetch()` +2. Вставляет его в `#app-content` +3. Подключает соответствующий JS-модуль из `js/views/{tab}.js` +4. Обновляет заголовок страницы (`#page-title`) + +### Разделы админ-панели + +| Tab | Описание | API | +|-----|----------|-----| +| `users` | CRUD пользователей | `/api/users` | +| `groups` | CRUD групп | `/api/groups` | +| `edu-forms` | Формы обучения | `/api/education-forms` | +| `equipments` | Оборудование | `/api/equipments` | +| `classrooms` | Аудитории | `/api/classrooms` | +| `subjects` | Дисциплины | `/api/subjects` | +| `schedule` | Расписание | `/api/users/lessons` | +| `database` | Тенанты | `/api/database` | +| `department` | Кафедры | `/api/departments` | +| `departments-data` | Создание кафедры/специальности | `/api/departments` | + +### Страница настроек (`/admin/settings/`) + +Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью. + +- Доступ: через dropdown «Настройки» в footer боковой панели админки +- Кнопка «Назад в панель» для возврата в `/admin/` +- Текущие вкладки: + - **Общие настройки** — заглушка (в разработке) + +--- + +## API-клиент (`api.js`) + +Все HTTP-запросы проходят через обёртку `apiFetch()`: + +```javascript +export async function apiFetch(endpoint, method = 'GET', body = null) { + const response = await fetch(endpoint, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: body ? JSON.stringify(body) : null + }); + + if (!response.ok) { + throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); + } + + return await response.json(); +} + +// Shortcut-методы +export const api = { + get: (url) => apiFetch(url, 'GET'), + post: (url, body) => apiFetch(url, 'POST', body), + put: (url, body) => apiFetch(url, 'PUT', body), + delete: (url, body) => apiFetch(url, 'DELETE', body) +}; +``` + +Токен берётся из `localStorage.getItem('token')`. + +--- + +## Аутентификация (Frontend) + +### Страница входа (`/index.html`) + +1. Пользователь вводит логин/пароль +2. `script.js` отправляет `POST /api/auth/login` +3. При успехе сохраняет в `localStorage`: + - `token` — UUID-токен + - `role` — роль пользователя +4. Перенаправляет на соответствующий интерфейс: + - `ADMIN` → `/admin/` + - `TEACHER` → `/teacher/` + - `STUDENT` → `/student/` + +### Проверка авторизации + +На каждой странице проверяется наличие токена и роли: + +```javascript +export function isAuthenticatedAsAdmin() { + const role = localStorage.getItem('role'); + return token && role === 'ADMIN'; +} +``` + +### Выход + +Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. + +--- + +## CSS-архитектура + +### Модульный подход + +Стили разделены на модульные файлы (порядок подключения важен): + +1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема +2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive +3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle +4. **`modals.css`** — Модальные окна +5. **`department.css`** — Стили страницы кафедр +6. **`departments-data.css`** — Стили создания кафедры/специальности + +### Темизация + +CSS-переменные позволяют поддерживать светлую/тёмную тему: + +```css +:root { + --bg-primary: #ffffff; + --text-primary: #1a1a2e; + --accent: #6366f1; +} + +[data-theme="dark"] { + --bg-primary: #0f0f23; + --text-primary: #e2e8f0; + --accent: #818cf8; +} +``` + +Переключение — через `theme-toggle.js`. + +--- + +## Боковая панель (Sidebar) + +- **Скрытие/раскрытие** — кнопка-крестик в правом верхнем углу sidebar +- **Десктоп** (`>768px`): sidebar складывается влево, контент расширяется; состояние сохраняется в `localStorage` (`sidebar-collapsed`) +- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением +- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода + +--- + +## OpenTelemetry (`otel.js`) + +Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`. + +- **На production** — загружается автоматически через динамический `import()` +- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh` + +```javascript +if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { + import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); +} +``` + +--- + +## Адаптивность + +Интерфейс адаптирован под мобильные устройства: +- Sidebar скрывается на экранах < 768px, выезжает как overlay +- Появляется кнопка-гамбургер (`#menu-toggle`) +- Кнопка-крестик закрывает sidebar на всех устройствах +- Таблицы получают горизонтальный скролл diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md new file mode 100644 index 0000000..6c80225 --- /dev/null +++ b/docs/INFRASTRUCTURE.md @@ -0,0 +1,137 @@ +# 🏭 Инфраструктура + +## Docker Compose (локальная разработка) + +### Сервисы + +```yaml +services: + backend: # Spring Boot (Java 17), порт 8080 + frontend: # Apache httpd:alpine, порт 80 + db: # PostgreSQL alpine3.23, порт 5432 +``` + +### Сеть + +Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском: + +```bash +docker network create proxy +``` + +### Переменные окружения + +Файл `.env` в корне проекта: + +```env +POSTGRES_USER=myuser +POSTGRES_PASSWORD=supersecretpassword +``` + +### Dockerfile (Backend) + +Backend собирается через multi-stage сборку Maven: +1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package` +2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar` + +### Dockerfile (Frontend) + +```dockerfile +FROM httpd:alpine +COPY . /usr/local/apache2/htdocs/ +RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ +``` + +--- + +## Kubernetes (продакшн) + +### Расположение конфигурации + +Файлы Kubernetes манифестов: `../k8s/` + +### Ключевые ресурсы + +| Ресурс | Тип | Описание | +|--------|-----|----------| +| `backend` | Deployment | Spring Boot приложение | +| `frontend` | Deployment | Apache httpd | +| `tenants-config` | ConfigMap | JSON-список тенантов | + +### ConfigMap для тенантов + +ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. + +При добавлении тенанта через API: +1. `DatabaseController` обновляет in-memory DataSource +2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API +3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек) + +### Обновление backend + +```bash +kubectl rollout restart deployment backend -n magistr +``` + +--- + +## Caddy (реверс-прокси) + +**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис + +В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`: +- Автоматическое получение TLS-сертификатов (Let's Encrypt) +- Маршрутизация `/api/*` → backend:8080 +- Маршрутизация статики → frontend:80 + +--- + +## CI/CD (Gitea Actions) + +### Пайплайн сборки Docker-образов + +Расположение: `.gitea/workflows/docker-build.yaml` + +Основные шаги: +1. Checkout кода +2. Login в Docker Registry +3. Build + Push образов (`backend`, `frontend`) +4. Генерация меток через `docker/metadata-action` + +--- + +## Мониторинг (SigNoz + OpenTelemetry) + +### Архитектура мониторинга + +```mermaid +graph LR + Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"] + Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector + Collector --> SigNoz["SigNoz"] + + Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"] +``` + +### Интеграция Backend + +Backend отправляет через OpenTelemetry: +- **Логи** — через Logback + OTLP exporter +- **Трейсы** — автоинструментация Spring Boot +- **Метрики** — JVM метрики, HTTP метрики + +Tenant ID добавляется в: +- MDC (логи): `MDC.put("tenant.id", tenant)` +- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)` + +### Интеграция Frontend + +Файл `admin/js/otel.js` — клиентская телеметрия: +- Метрики производительности страниц +- Трейсы пользовательских действий + +### Дашборды SigNoz + +- JVM Dashboard (Heap, GC, Threads) +- PostgreSQL Dashboard (Connections, Queries) +- HTTP Dashboard (Requests, Latency, Errors) diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 0000000..41ad688 --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,167 @@ +# 📋 Логирование + +## Стек технологий + +| Компонент | Технология | +|-----------|------------| +| Фасад | SLF4J (`org.slf4j.Logger`) | +| Реализация | Logback (поставляется с `spring-boot-starter-web`) | +| Конфигурация | Стандартная Spring Boot (без кастомного `logback.xml`) | +| Экспорт (прод) | OpenTelemetry Java Agent → OTLP → SigNoz | +| Контекст тенанта | SLF4J MDC (`tenant.id`) | + +--- + +## Архитектура + +```mermaid +graph LR + Code["Java-код
log.info(...)"] --> SLF4J["SLF4J API"] + SLF4J --> Logback["Logback"] + Logback -->|"Локальная разработка"| Console["stdout / stderr"] + Logback -->|"Продакшн"| OTelAgent["OTel Java Agent
(Logback Appender)"] + OTelAgent -->|"OTLP HTTP"| SigNoz["SigNoz"] +``` + +### Локальная разработка + +Логи выводятся в `stdout` контейнера в стандартном формате Spring Boot: + +``` +2026-03-22 12:00:00.123 INFO 1 --- [main] c.m.app.config.DataInitializer : Initializing databases for 1 tenant(s)... +``` + +Просмотр логов: + +```bash +docker compose logs -f backend +``` + +### Продакшн (Kubernetes) + +OpenTelemetry Java Agent подключается как `-javaagent` в [Dockerfile](file:///mnt/HDD/magistr/magistr/backend/Dockerfile) и автоматически перехватывает логи Logback, экспортируя их в SigNoz по OTLP. + +```dockerfile +ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"] +``` + +Конфигурация агента задаётся через переменные окружения в [backend.yaml](file:///mnt/HDD/magistr/k8s/backend.yaml): + +| Переменная | Значение | Назначение | +|------------|----------|------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://192.168.1.100:4318` | Адрес SigNoz Collector | +| `OTEL_SERVICE_NAME` | `magistr-backend` | Имя сервиса в SigNoz | +| `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=default` | Окружение | +| `OTEL_LOGS_EXPORTER` | `otlp` | Экспорт логов через OTLP | +| `OTEL_METRICS_EXPORTER` | `otlp` | Экспорт метрик через OTLP | +| `OTEL_TRACES_EXPORTER` | `otlp` | Экспорт трейсов через OTLP | +| `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES` | `tenant.id` | Захват MDC-атрибута в логи | + +> [!NOTE] +> В локальной разработке OpenTelemetry Agent также встроен в Docker-образ, но без переменных `OTEL_*` он работает в режиме noop — логи идут только в stdout. + +--- + +## Мультитенантный контекст (MDC) + +Каждый HTTP-запрос обогащается tenant ID через [TenantInterceptor](file:///mnt/HDD/magistr/magistr/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java): + +```java +// preHandle — при входе запроса +MDC.put("tenant.id", tenant); +Span.current().setAttribute("tenant.id", tenant); + +// afterCompletion — после завершения +MDC.remove("tenant.id"); +``` + +Это позволяет: +- Фильтровать логи по тенанту в SigNoz +- Коррелировать логи с трейсами через Span-атрибуты +- Идентифицировать, к какому университету относится каждая запись + +--- + +## Использование в коде + +### Классы с логированием + +| Класс | Уровни | Что логируется | +|-------|--------|----------------| +| `TenantInterceptor` | DEBUG, WARN | Резолвинг тенанта, неизвестный тенант (404) | +| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 | +| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения | +| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции | +| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s | +| `DataInitializer` | INFO | Инициализация БД при старте | +| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация | + +### Паттерн использования + +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MyClass { + private static final Logger log = LoggerFactory.getLogger(MyClass.class); + + public void doSomething() { + log.info("Операция выполнена: param={}", value); + log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом + } +} +``` + +### Рекомендации по уровням + +| Уровень | Когда использовать | +|---------|-------------------| +| `ERROR` | Необработанные ошибки, сбои подключения к БД, провалы миграций | +| `WARN` | Неизвестный тенант, нет конфигурации, fallback-сценарии | +| `INFO` | Успешные операции, CRUD-действия, старт/стоп компонентов | +| `DEBUG` | Детали резолвинга тенанта, ping-запросы | + +--- + +## Настройка уровня логирования + +В [application.properties](file:///mnt/HDD/magistr/magistr/backend/src/main/resources/application.properties) (по умолчанию закомментировано): + +```properties +# Включить DEBUG для всего приложения +#logging.level.root=DEBUG + +# Только для пакета приложения +logging.level.com.magistr.app=DEBUG + +# Только для конкретного класса +logging.level.com.magistr.app.config.tenant.TenantInterceptor=DEBUG +``` + +Также можно задавать через переменные окружения: + +```bash +LOGGING_LEVEL_ROOT=DEBUG +LOGGING_LEVEL_COM_MAGISTR_APP=DEBUG +``` + +--- + +## Просмотр логов + +### Локально (Docker Compose) + +```bash +# Все логи backend +docker compose logs -f backend + +# Фильтрация по ключевому слову +docker compose logs -f backend | grep "tenant" +``` + +### Продакшн (SigNoz) + +Логи доступны в веб-интерфейсе SigNoz → раздел **Logs**: +- Фильтрация по `service.name = magistr-backend` +- Фильтрация по `tenant.id` (из MDC) +- Корреляция с трейсами через общий `trace_id` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3775cc5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,113 @@ +# 📚 Magistr — Система управления университетским расписанием + +## Обзор + +**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. + +- **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company) +- **Локальная разработка:** [http://localhost:80](http://localhost:80) + +--- + +## Стек технологий + +| Компонент | Технология | +|-----------|-----------| +| **Backend** | Java 17, Spring Boot 3.2.5 | +| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS | +| **База данных** | PostgreSQL (через Flyway миграции) | +| **Контейнеризация** | Docker, Docker Compose | +| **Продакшн** | Kubernetes, Caddy (реверс-прокси) | +| **Мониторинг** | SigNoz, OpenTelemetry | +| **CI/CD** | Gitea Actions | + +--- + +## Быстрый старт + +### Предварительные требования + +- Docker и Docker Compose +- Git + +### Локальный запуск + +```bash +# 1. Клонировать репозиторий +git clone magistr && cd magistr + +# 2. Создать Docker-сеть (если ещё не создана) +docker network create proxy + +# 3. Запустить все сервисы +docker compose up -d --build +``` + +После запуска приложение доступно по адресу: **http://localhost:80** + +**Учётные данные по умолчанию:** + +| Логин | Пароль | Роль | +|-------|--------|------| +| `admin` | `admin` | Администратор | +| `Тестовый преподаватель` | `1234567890` | Преподаватель | + +### Полезные команды + +```bash +# Просмотр логов +docker compose logs -f backend + +# Полный сброс базы данных (удаление данных + повтор миграций) +docker compose down -v +docker compose up -d + +# Остановка всех сервисов +docker compose down +``` + +--- + +## Структура проекта + +``` +magistr/ +├── backend/ # Java Spring Boot backend +│ └── src/main/ +│ ├── java/com/magistr/app/ +│ │ ├── controller/ # REST-контроллеры (10 шт.) +│ │ ├── model/ # JPA-сущности +│ │ ├── dto/ # Data Transfer Objects +│ │ ├── repository/ # Spring Data JPA репозитории +│ │ ├── config/ # Конфигурация приложения +│ │ │ └── tenant/ # Мультитенантность +│ │ └── utils/ # Валидаторы +│ └── resources/ +│ ├── application.properties +│ └── db/migration/ # Flyway SQL миграции +├── frontend/ # Статический фронтенд +│ ├── index.html # Страница авторизации +│ ├── admin/ # Админ-панель (деканат) +│ │ ├── js/views/ # Модули представлений +│ │ └── css/ # Стили +│ ├── teacher/ # Интерфейс преподавателя +│ └── student/ # Интерфейс студента +├── docs/ # 📖 Документация (вы здесь) +├── compose.yaml # Docker Compose конфигурация +├── .env # Переменные окружения +└── AGENTS.md # Руководство для AI-агентов +``` + +--- + +## 📖 Навигация по документации + +| Документ | Содержание | +|----------|-----------| +| [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов | +| [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами | +| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции | +| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов | +| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг | +| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков | +| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили | diff --git a/docs/UI_COMPONENTS.md b/docs/UI_COMPONENTS.md new file mode 100644 index 0000000..3c440b1 --- /dev/null +++ b/docs/UI_COMPONENTS.md @@ -0,0 +1,115 @@ +# 🎨 Использование UI компонентов: Выпадающие списки (Dropdowns) + +В проекте Magistr используется **премиальная кастомная дизайн-система** выпадающих списков. В связи с ограничениями браузеров на стилизацию стандартных элементов `` на всём сайте, превращая их в красивые выпадающие меню. Вам **не нужно** писать сложный HTML, всё работает автоматически! + +### Как добавить новый одинарный список: + +Просто добавьте обычный тег ` + + + + + +``` + +### Как это работает: +1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**. +2. Как только любой скрипт или загрузка страницы добавляет `` (но оставляет его доступным из JS!). + - Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона. + - Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`). + +### Динамическое обновление списка (через JS): +Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно: + +```javascript +const select = document.getElementById('my-new-select'); +select.innerHTML = ''; +``` +**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `' + + departments.map(d => ``).join(''); + } catch (e) { + departmentSelect.innerHTML = ''; + } + + // ===== Восстанавливаем ранее загруженные таблицы из sessionStorage ===== + restoreScheduleBlocks(); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('schedule-form-alert'); + + const departmentId = departmentSelect.value; + const period = document.getElementById('filter-period').value; + const semesterType = document.querySelector('input[name="semesterType"]:checked')?.value; + + if (!departmentId || !period || !semesterType) { + showAlert('schedule-form-alert', 'Заполните все поля', 'error'); + return; + } + + const deptName = departmentSelect.options[departmentSelect.selectedIndex].text; + + try { + const params = new URLSearchParams({ departmentId, semesterType, period }); + const data = await api.get(`/api/department/schedule?${params.toString()}`); + + const semesterName = semesterType === 'spring' ? 'весенний' : (semesterType === 'autumn' ? 'осенний' : semesterType); + const periodName = period.replace('-', '/'); + + renderScheduleBlock(deptName, semesterName, periodName, data, departmentId, semesterType, period); + + // НЕ сбрасываем форму — фильтры остаются заполненными (fix #3) + + } catch (err) { + showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error'); + } + }); + + // ===== Уникальный ключ для таблицы по параметрам ===== + function blockKey(departmentId, semesterType, period) { + return `${departmentId}_${semesterType}_${period}`; + } + + // ===== Рендер блока таблицы (с дедупликацией — fix #6) ===== + function renderScheduleBlock(deptName, semester, period, schedule, departmentId, semesterType, rawPeriod) { + const key = blockKey(departmentId, semesterType, rawPeriod); + + // Удаляем ранее загруженный блок с тем же ключом + const existing = container.querySelector(`[data-block-key="${key}"]`); + if (existing) existing.remove(); + + const details = document.createElement('details'); + details.className = 'table-item'; + details.open = true; + details.setAttribute('data-block-key', key); + details.innerHTML = ` + + +
+ Данные к составлению расписания + Кафедра: ${escapeHtml(deptName)} + Семестр: ${escapeHtml(semester)} + Уч. год: ${escapeHtml(period)} +
+
${Array.isArray(schedule) ? schedule.length : 0} записей
+
+
+ + + + + + + + + + + + + + + ${renderRows(schedule)} + +
СпециальностьКурс/семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыПреподаватель
+
+ `; + + container.prepend(details); + + // Сохраняем в sessionStorage + saveScheduleBlock(key, { deptName, semester, period, schedule, departmentId, semesterType, rawPeriod }); + } + + function renderRows(schedule) { + if (!Array.isArray(schedule) || schedule.length === 0) { + return 'Нет данных'; + } + return schedule.map(r => ` + + ${escapeHtml(r.specialityCode || '-')} + ${(() => { + const course = r.groupCourse || '-'; + const semester = r.semester || '-'; + if (course === '-' && semester === '-') return '-'; + return `${course} | ${semester}`; + })()} + ${escapeHtml(r.groupName || '-')} + ${escapeHtml(r.subjectName || '-')} + ${escapeHtml(r.lessonType || '-')} + ${escapeHtml(r.numberOfHours || '-')} + ${r.division === true ? '✓' : ''} + ${(() => { + const jobTitle = r.teacherJobTitle || '-'; + const teacherName = r.teacherName || '-'; + if (jobTitle === '-' && teacherName === '-') return '-'; + return `${jobTitle}, ${teacherName}`; + })()} + + `).join(''); + } + + // ===== Persistence: sessionStorage (fix #4) ===== + function saveScheduleBlock(key, blockData) { + try { + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); + stored[key] = blockData; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + } catch (e) { + console.warn('Ошибка сохранения в sessionStorage:', e); + } + } + + function restoreScheduleBlocks() { + try { + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); + const keys = Object.keys(stored); + if (keys.length === 0) return; + + keys.forEach(key => { + const b = stored[key]; + renderScheduleBlock(b.deptName, b.semester, b.period, b.schedule, b.departmentId, b.semesterType, b.rawPeriod); + }); + } catch (e) { + console.warn('Ошибка восстановления из sessionStorage:', e); + } + } + + // ========================================================= + // ЛОГИКА ДЛЯ ФУНКЦИОНАЛА "СОЗДАТЬ ЗАПИСЬ (К/Ф)" + // Два модальных окна поверх всего контента в одном оверлее + // ========================================================= + const btnCreateSchedule = document.getElementById('btn-create-schedule'); + const csOverlay = document.getElementById('cs-overlay'); + + const modalCreateSchedule = document.getElementById('modal-create-schedule'); + const modalCreateScheduleClose = document.getElementById('modal-create-schedule-close'); + const formCreateSchedule = document.getElementById('create-schedule-form'); + + const modalViewSchedules = document.getElementById('modal-view-schedules'); + const btnSaveSchedules = document.getElementById('btn-save-schedules'); + const preparedSchedulesTbody = document.getElementById('prepared-schedules-tbody'); + + const csGroupSelect = document.getElementById('cs-group'); + const csSubjectSelect = document.getElementById('cs-subject'); + const csTeacherSelect = document.getElementById('cs-teacher'); + const csDepartmentIdInput = document.getElementById('cs-department-id'); + + let preparedSchedules = []; + let csGroups = []; + let csSubjects = []; + let csTeachers = []; + + const SEMESTER_LABELS = { autumn: 'Осенний', spring: 'Весенний' }; + const LESSON_TYPE_LABELS = { 1: 'Лекция', 2: 'Практическая работа', 3: 'Лабораторная работа' }; + + const localDepartmentId = localStorage.getItem('departmentId'); + + // ===== Загрузка справочников ===== + async function loadDictionariesForSchedule() { + try { + csGroups = await api.get('/api/groups'); + csGroupSelect.innerHTML = '' + + csGroups.map(g => ``).join(''); + + csSubjects = await api.get('/api/subjects'); + csSubjectSelect.innerHTML = '' + + csSubjects.map(s => ``).join(''); + + // Загрузка преподавателей: сначала по кафедре, при ошибке — все преподаватели + csTeachers = []; + if (localDepartmentId) { + try { + csTeachers = await api.get(`/api/users/teachers/${localDepartmentId}`); + } catch (e) { + console.warn('Не удалось загрузить преподавателей для кафедры, загружаем всех:', e); + } + } + // Фолбэк: загружаем всех преподавателей + if (!Array.isArray(csTeachers) || csTeachers.length === 0) { + try { + csTeachers = await api.get('/api/users/teachers'); + } catch (e2) { + console.error('Ошибка загрузки всех преподавателей:', e2); + } + } + if (Array.isArray(csTeachers) && csTeachers.length > 0) { + csTeacherSelect.innerHTML = '' + + csTeachers.map(t => ``).join(''); + } else { + csTeacherSelect.innerHTML = ''; + } + } catch (e) { + console.error('Ошибка загрузки справочников:', e); + } + } + + loadDictionariesForSchedule(); + + // ===== Открытие / Закрытие оверлея ===== + function openOverlay() { + csOverlay.classList.add('open'); + document.body.style.overflow = 'hidden'; + } + + function closeOverlay() { + csOverlay.classList.remove('open'); + document.body.style.overflow = ''; + hideAlert('create-schedule-alert'); + hideAlert('save-schedules-alert'); + } + + function updateTableVisibility() { + modalViewSchedules.style.display = preparedSchedules.length > 0 ? '' : 'none'; + } + + // ===== Кнопка «Создать запись» ===== + btnCreateSchedule.addEventListener('click', () => { + if (localDepartmentId) { + csDepartmentIdInput.value = localDepartmentId; + } else { + showAlert('schedule-form-alert', 'Требуется перезайти (отсутствует ID кафедры)', 'error'); + return; + } + openOverlay(); + }); + + // ===== Закрытие ===== + modalCreateScheduleClose.addEventListener('click', closeOverlay); + + csOverlay.addEventListener('click', (e) => { + if (e.target === csOverlay || e.target.classList.contains('cs-overlay-scroll')) { + closeOverlay(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && csOverlay.classList.contains('open')) { + closeOverlay(); + } + }); + + // ===== Рендер таблицы подготовленных записей ===== + function renderPreparedSchedules() { + if (preparedSchedules.length === 0) { + preparedSchedulesTbody.innerHTML = 'Нет записей'; + return; + } + preparedSchedulesTbody.innerHTML = preparedSchedules.map((s, index) => { + const groupName = csGroups.find(g => g.id == s.groupId)?.name || s.groupId; + const subjectName = csSubjects.find(sub => sub.id == s.subjectsId)?.name || s.subjectsId; + const teacherName = csTeachers.find(t => t.id == s.teacherId)?.fullName + || csTeachers.find(t => t.id == s.teacherId)?.username || s.teacherId; + const lessonTypeName = LESSON_TYPE_LABELS[s.lessonTypeId] || 'Неизвестно'; + const semLabel = SEMESTER_LABELS[s.semesterType] || s.semesterType; + const periodDisplay = s.period.replace('-', '/'); + const divText = s.isDivision ? '✓' : ''; + const hasError = !!s._errorMsg; + const rowStyle = hasError ? ' style="background: rgba(239, 68, 68, 0.08);"' : ''; + let row = ` + + ${escapeHtml(periodDisplay)} + ${escapeHtml(semLabel)} + ${escapeHtml(String(groupName))} + ${escapeHtml(String(subjectName))} + ${escapeHtml(lessonTypeName)} + ${s.numberOfHours} + ${divText} + ${escapeHtml(String(teacherName))} + + `; + if (hasError) { + row += ` + + ⚠ ${escapeHtml(s._errorMsg)} + + `; + } + return row; + }).join(''); + } + + // ===== Удаление строки из таблицы ===== + preparedSchedulesTbody.addEventListener('click', (e) => { + if (e.target.classList.contains('btn-delete')) { + const idx = parseInt(e.target.getAttribute('data-index'), 10); + preparedSchedules.splice(idx, 1); + renderPreparedSchedules(); + updateTableVisibility(); + } + }); + + // ===== Очистка полей формы (частичная) ===== + function clearFormFields() { + document.getElementById('cs-hours').value = ''; + document.getElementById('cs-division').checked = false; + } + + // ===== Добавление записи в список ===== + formCreateSchedule.addEventListener('submit', (e) => { + e.preventDefault(); + hideAlert('create-schedule-alert'); + + const depId = csDepartmentIdInput.value; + const period = document.getElementById('cs-period').value; + const semesterType = document.querySelector('input[name="csSemesterType"]:checked')?.value; + const groupId = csGroupSelect.value; + const subjectId = csSubjectSelect.value; + const lessonTypeId = document.getElementById('cs-lesson-type').value; + const hours = document.getElementById('cs-hours').value; + const isDivision = document.getElementById('cs-division').checked; + const teacherId = csTeacherSelect.value; + + if (!period || !semesterType || !groupId || !subjectId || !lessonTypeId || !hours || !teacherId) { + showAlert('create-schedule-alert', 'Заполните все обязательные поля', 'error'); + return; + } + + const newRecord = { + departmentId: Number(depId), + groupId: Number(groupId), + subjectsId: Number(subjectId), + lessonTypeId: Number(lessonTypeId), + numberOfHours: Number(hours), + isDivision: isDivision, + teacherId: Number(teacherId), + semesterType: semesterType, + period: period + }; + + // Проверка на дубликат + const isDuplicate = preparedSchedules.some(s => + s.period === newRecord.period && + s.semesterType === newRecord.semesterType && + s.groupId === newRecord.groupId && + s.subjectsId === newRecord.subjectsId && + s.lessonTypeId === newRecord.lessonTypeId && + s.numberOfHours === newRecord.numberOfHours && + s.isDivision === newRecord.isDivision && + s.teacherId === newRecord.teacherId + ); + + if (isDuplicate) { + showAlert('create-schedule-alert', 'Такая запись уже есть в списке', 'error'); + return; + } + + preparedSchedules.push(newRecord); + + clearFormFields(); + + showAlert('create-schedule-alert', 'Запись добавлена ✓', 'success'); + setTimeout(() => hideAlert('create-schedule-alert'), 4000); // fix #1: 4 секунды + + renderPreparedSchedules(); + updateTableVisibility(); + }); + + // ===== Сохранение в БД ===== + btnSaveSchedules.addEventListener('click', async () => { + if (preparedSchedules.length === 0) { + showAlert('save-schedules-alert', 'Нет записей для сохранения', 'error'); + return; + } + + btnSaveSchedules.disabled = true; + btnSaveSchedules.textContent = 'Сохранение...'; + hideAlert('save-schedules-alert'); + + let errors = 0; + let saved = 0; + const failedRecords = []; + + for (const record of preparedSchedules) { + try { + await api.post('/api/department/schedule/create', record); + saved++; + } catch (err) { + console.error('Ошибка сохранения записи:', err); + errors++; + const isDuplicate = err.status === 409 || + (err.message && err.message.toLowerCase().includes('уже существует')); + failedRecords.push({ + ...record, + _errorMsg: isDuplicate + ? 'Такая запись уже есть в базе данных' + : (err.message || 'Ошибка сохранения') + }); + } + } + + btnSaveSchedules.disabled = false; + btnSaveSchedules.textContent = 'Сохранить в БД'; + + if (errors === 0) { + showAlert('save-schedules-alert', `Все записи (${saved}) успешно сохранены!`, 'success'); + preparedSchedules = []; + renderPreparedSchedules(); + updateTableVisibility(); + setTimeout(closeOverlay, 2000); + } else { + preparedSchedules = failedRecords; + renderPreparedSchedules(); + if (saved > 0) { + showAlert('save-schedules-alert', + `Сохранено: ${saved}. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); + } else { + showAlert('save-schedules-alert', + `Не удалось сохранить. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); + } + } + }); + +} \ No newline at end of file diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js new file mode 100644 index 0000000..489a188 --- /dev/null +++ b/frontend/admin/js/views/departments-data.js @@ -0,0 +1,103 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +export async function initDepartmentsData() { + const deptTbody = document.getElementById('departments-tbody'); + const specTbody = document.getElementById('specialties-tbody'); + + const createDeptForm = document.getElementById('create-department-form'); + const createSpecForm = document.getElementById('create-specialty-form'); + + let departments = []; + let specialties = []; + + async function loadData() { + // Load Departments + try { + departments = await api.get('/api/departments'); + renderDepartments(); + } catch (e) { + deptTbody.innerHTML = '-'; + } + + // Load Specialties + try { + specialties = await api.get('/api/specialties'); + renderSpecialties(); + } catch (e) { + specTbody.innerHTML = '-'; + } + } + + function renderDepartments() { + if (!departments || !departments.length) { + deptTbody.innerHTML = '-'; + return; + } + deptTbody.innerHTML = departments.map(d => ` + + ${d.id} + ${escapeHtml(d.departmentName || d.name)} + ${escapeHtml(String(d.departmentCode || d.code))} + + `).join(''); + } + + function renderSpecialties() { + if (!specialties || !specialties.length) { + specTbody.innerHTML = '-'; + return; + } + specTbody.innerHTML = specialties.map(s => ` + + ${s.id} + ${escapeHtml(s.specialityName || s.name)} + ${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} + + `).join(''); + } + + createDeptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-dept-alert'); + const name = document.getElementById('dept-name').value.trim(); + const code = document.getElementById('dept-code').value.trim(); + + if (!name || !code) { + showAlert('create-dept-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) }); + showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success'); + createDeptForm.reset(); + loadData(); + } catch (error) { + showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error'); + } + }); + + createSpecForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-spec-alert'); + const name = document.getElementById('spec-name').value.trim(); + const code = document.getElementById('spec-code').value.trim(); + + if (!name || !code) { + showAlert('create-spec-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.post('/api/specialties', { specialityName: name, specialityCode: code }); + showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success'); + createSpecForm.reset(); + loadData(); + } catch (error) { + showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error'); + } + }); + + loadData(); +} diff --git a/frontend/admin/js/views/equipments.js b/frontend/admin/js/views/equipments.js index 9f7a083..c3045e6 100755 --- a/frontend/admin/js/views/equipments.js +++ b/frontend/admin/js/views/equipments.js @@ -24,7 +24,9 @@ export function renderEquipmentCheckboxes(equipments, containerId, textId, check const isChecked = checkedIds.includes(eq.id) ? 'checked' : ''; return ` `}).join(''); updateSelectText(containerId, textId); diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 17301b5..ec57a24 100755 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -17,7 +17,7 @@ export async function initGroups() { populateEfSelects(educationForms); await loadGroups(); } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки данных'; + groupsTbody.innerHTML = 'Ошибка загрузки данных'; } } @@ -26,7 +26,7 @@ export async function initGroups() { allGroups = await api.get('/api/groups'); applyGroupFilter(); } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки'; + groupsTbody.innerHTML = 'Ошибка загрузки'; } } @@ -61,7 +61,7 @@ export async function initGroups() { function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + groupsTbody.innerHTML = 'Нет групп'; return; } groupsTbody.innerHTML = groups.map(g => ` @@ -70,6 +70,9 @@ export async function initGroups() { ${escapeHtml(g.name)} ${escapeHtml(g.groupSize)} ${escapeHtml(g.educationFormName)} + ${g.departmentId || '-'} + ${g.course || '-'} + ${escapeHtml(g.specialityCode || '-')} `).join(''); } @@ -80,14 +83,27 @@ export async function initGroups() { const name = document.getElementById('new-group-name').value.trim(); const groupSize = document.getElementById('new-group-size').value; const educationFormId = newGroupEfSelect.value; + const departmentId = document.getElementById('new-group-department').value; + const course = document.getElementById('new-group-course').value; + const specialityCode = document.getElementById('new-group-speciality-code').value.trim(); if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } + if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; } + if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; } + if (!specialityCode) { showAlert('create-group-alert', 'Введите код специальности', 'error'); return; } try { - const data = await api.post('/api/groups', { name, groupSize, educationFormId: Number(educationFormId) }); - showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success'); + const data = await api.post('/api/groups', { + name, + groupSize: Number(groupSize), + educationFormId: Number(educationFormId), + departmentId: Number(departmentId), + course: Number(course), + specialityCode: specialityCode + }); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); createGroupForm.reset(); loadGroups(); } catch (e) { diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 03c2950..57ca394 100755 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -1,5 +1,5 @@ import { api } from '../api.js'; -import { escapeHtml } from '../utils.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; export async function initSchedule() { const tbody = document.getElementById('schedule-tbody'); @@ -20,7 +20,6 @@ export async function initSchedule() { // ===================== Фильтрация ===================== - // Извлечение отображаемого значения поля для фильтрации function getDisplayValue(lesson, key) { switch (key) { case 'teacher': @@ -38,20 +37,17 @@ export async function initSchedule() { } } - // Собрать уникальные значения из данных function getUniqueValues(key) { const vals = new Set(); lessonsData.forEach(lesson => { vals.add(getDisplayValue(lesson, key)); }); - // Для дней — сортируем по порядку if (key === 'day') { return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99)); } return [...vals].sort((a, b) => a.localeCompare(b, 'ru')); } - // Применить все фильтры function applyFilters(lessons) { return lessons.filter(lesson => { for (const key of Object.keys(activeFilters)) { @@ -79,7 +75,6 @@ export async function initSchedule() { function onDocumentClick(e) { if (currentPopup && !currentPopup.contains(e.target)) { - // Проверяем, не кликнули ли по иконке фильтра if (!e.target.closest('.filter-icon')) { closePopup(); } @@ -87,7 +82,6 @@ export async function initSchedule() { } function openFilterPopup(th, filterKey) { - // Если уже открыт этот же — закрыть if (currentPopup && currentPopup.dataset.filterKey === filterKey) { closePopup(); return; @@ -97,19 +91,16 @@ export async function initSchedule() { const uniqueValues = getUniqueValues(filterKey); const currentFilter = activeFilters[filterKey]; - // Создаём попап const popup = document.createElement('div'); popup.className = 'filter-popup'; popup.dataset.filterKey = filterKey; - // Поисковое поле const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'filter-search'; searchInput.placeholder = 'Поиск...'; popup.appendChild(searchInput); - // Кнопки «Выбрать все» / «Сбросить» const btnRow = document.createElement('div'); btnRow.className = 'filter-btn-row'; @@ -133,7 +124,6 @@ export async function initSchedule() { btnRow.appendChild(btnNone); popup.appendChild(btnRow); - // Список чекбоксов const listWrap = document.createElement('div'); listWrap.className = 'filter-list'; @@ -146,7 +136,6 @@ export async function initSchedule() { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = val; - // Если фильтр активен — отмечаем только выбранные; если нет — все отмечены cb.checked = currentFilter ? currentFilter.has(val) : true; const span = document.createElement('span'); @@ -160,7 +149,6 @@ export async function initSchedule() { popup.appendChild(listWrap); - // Кнопка «Применить» const btnApply = document.createElement('button'); btnApply.className = 'filter-btn-apply'; btnApply.textContent = 'Применить'; @@ -171,7 +159,6 @@ export async function initSchedule() { if (cb.checked) selected.add(cb.value); }); - // Если все выбраны — снимаем фильтр if (selected.size === uniqueValues.length) { delete activeFilters[filterKey]; th.classList.remove('filter-active'); @@ -185,7 +172,6 @@ export async function initSchedule() { }); popup.appendChild(btnApply); - // Поиск по чекбоксам searchInput.addEventListener('input', () => { const query = searchInput.value.toLowerCase(); listWrap.querySelectorAll('.filter-item').forEach(item => { @@ -194,28 +180,22 @@ export async function initSchedule() { }); }); - // Предотвращаем всплытие кликов внутри попапа (чтобы не срабатывала сортировка th) popup.addEventListener('click', (e) => e.stopPropagation()); searchInput.addEventListener('click', (e) => e.stopPropagation()); - // Позиционируем попап под th th.style.position = 'relative'; th.appendChild(popup); currentPopup = popup; - // Фокус на поиск setTimeout(() => searchInput.focus(), 50); - // Закрытие по клику вне setTimeout(() => { document.addEventListener('click', onDocumentClick, true); }, 10); } - // Обработчики кликов по заголовкам с фильтрами (клик по всей ячейке) table.querySelectorAll('thead th.filterable').forEach(th => { th.addEventListener('click', (e) => { - // Не открываем попап при клике внутри самого попапа if (e.target.closest('.filter-popup')) return; const filterKey = th.dataset.filterKey; openFilterPopup(th, filterKey); @@ -249,7 +229,6 @@ export async function initSchedule() { case 'week': return (lesson.week || '').toLowerCase(); case 'time': { - // Составной ключ: день + время для правильной сортировки const d = (lesson.day || '').toLowerCase(); const dayNum = dayOrder[d] ?? 99; const t = lesson.time || '99:99'; @@ -287,10 +266,8 @@ export async function initSchedule() { }); } - // Навешиваем обработчики клика на заголовки (сортировка) table.querySelectorAll('thead th.sortable').forEach(th => { th.addEventListener('click', (e) => { - // Не сортируем, если кликнули по иконке фильтра или внутри попапа if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return; const key = th.dataset.sortKey; @@ -310,7 +287,7 @@ export async function initSchedule() { }); }); - // ===================== Загрузка и рендер ===================== + // ===================== Загрузка и рендер таблицы ===================== async function loadSchedule() { try { @@ -318,21 +295,20 @@ export async function initSchedule() { lessonsData = lessons; renderSchedule(lessons); } catch (e) { - tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; + tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; } } function renderSchedule(lessons) { if (!lessons || !lessons.length) { - tbody.innerHTML = 'Нет занятий'; + tbody.innerHTML = 'Нет занятий'; return; } - // Сначала фильтруем, потом сортируем const filtered = applyFilters(lessons); if (!filtered.length) { - tbody.innerHTML = 'Нет занятий по выбранным фильтрам'; + tbody.innerHTML = 'Нет занятий по выбранным фильтрам'; return; } @@ -366,5 +342,343 @@ export async function initSchedule() { }).join(''); } - await loadSchedule(); + // ===================== Модалки добавления занятия ===================== + + const overlay = document.getElementById('sch-overlay'); + const modalForm = document.getElementById('sch-modal-form'); + const modalLessons = document.getElementById('sch-modal-lessons'); + const btnAddLesson = document.getElementById('sch-btn-add-lesson'); + const btnClose = document.getElementById('sch-modal-close'); + const addForm = document.getElementById('sch-add-lesson-form'); + + const schTeacherSelect = document.getElementById('sch-teacher'); + const schGroupSelect = document.getElementById('sch-group'); + const schDisciplineSelect = document.getElementById('sch-discipline'); + const schClassroomSelect = document.getElementById('sch-classroom'); + const schDaySelect = document.getElementById('sch-day'); + const schTimeSelect = document.getElementById('sch-time'); + const schTypeSelect = document.getElementById('sch-type'); + const schWeekUpper = document.getElementById('sch-week-upper'); + const schWeekLower = document.getElementById('sch-week-lower'); + const schFormatOffline = document.getElementById('sch-format-offline'); + + const schTeacherName = document.getElementById('sch-teacher-name'); + const schLessonsContainer = document.getElementById('sch-lessons-container'); + + let groups = []; + let subjects = []; + let classrooms = []; + let teachers = []; + + const weekdaysTimes = [ + "8:00-9:30", "9:40-11:10", "11:40-13:10", + "13:20-14:50", "15:00-16:30", "16:50-18:20", "18:30-19:00" + ]; + const saturdayTimes = [ + "8:20-9:50", "10:00-11:30", "11:40-13:10", "13:20-14:50" + ]; + + // ===== Загрузка справочников ===== + async function loadGroups() { + try { + groups = await api.get('/api/groups'); + schGroupSelect.innerHTML = '' + + groups.map(g => { + let text = escapeHtml(g.name); + if (g.groupSize) text += ` (числ: ${g.groupSize} чел.)`; + return ``; + }).join(''); + } catch (e) { console.error('Ошибка загрузки групп:', e); } + } + + async function loadSubjects() { + try { + subjects = await api.get('/api/subjects'); + schDisciplineSelect.innerHTML = '' + + subjects.map(s => ``).join(''); + } catch (e) { console.error('Ошибка загрузки дисциплин:', e); } + } + + async function loadClassrooms() { + try { + classrooms = await api.get('/api/classrooms'); + renderClassroomOptions(); + } catch (e) { console.error('Ошибка загрузки аудиторий:', e); } + } + + async function loadTeachers() { + try { + teachers = await api.get('/api/users/teachers'); + schTeacherSelect.innerHTML = '' + + teachers.map(t => ``).join(''); + } catch (e) { console.error('Ошибка загрузки преподавателей:', e); } + } + + function renderClassroomOptions() { + if (!classrooms || classrooms.length === 0) { + schClassroomSelect.innerHTML = ''; + return; + } + const selectedGroupId = schGroupSelect.value; + const selectedGroup = groups?.find(g => g.id == selectedGroupId); + const groupSize = selectedGroup?.groupSize || 0; + + schClassroomSelect.innerHTML = '' + + classrooms.map(c => { + let text = escapeHtml(c.name); + if (c.capacity) text += ` (вместимость: ${c.capacity} чел.)`; + if (c.isAvailable === false) { + text += ` ❌ Занята`; + } else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) { + text += ` ⚠️ Недостаточно места`; + } + return ``; + }).join(''); + } + + schGroupSelect.addEventListener('change', () => renderClassroomOptions()); + + function updateTimeOptions(dayValue) { + let times = []; + if (dayValue === "Суббота") { + times = saturdayTimes; + } else if (dayValue && dayValue !== '') { + times = weekdaysTimes; + } else { + schTimeSelect.innerHTML = ''; + schTimeSelect.disabled = true; + return; + } + schTimeSelect.innerHTML = '' + + times.map(t => ``).join(''); + schTimeSelect.disabled = false; + } + + schDaySelect.addEventListener('change', function () { + updateTimeOptions(this.value); + }); + + // ===== Автозаполнение преподавателя из фильтра ===== + function getFilteredTeacherId() { + const teacherFilter = activeFilters['teacher']; + if (teacherFilter && teacherFilter.size === 1) { + const teacherName = [...teacherFilter][0]; + // Сопоставляем по username, fullName и их комбинациям + const match = teachers.find(t => + t.username === teacherName || + t.fullName === teacherName || + (t.fullName || t.username) === teacherName + ); + return match ? String(match.id) : ''; + } + return ''; + } + + // ===== Загрузка занятий преподавателя ===== + async function loadTeacherLessons(teacherId) { + const teacher = teachers.find(t => t.id == teacherId); + const name = teacher ? (teacher.fullName || teacher.username) : ''; + schTeacherName.textContent = name + ? `Занятия преподавателя: ${name}` + : 'Занятия преподавателя'; + + modalLessons.style.display = ''; + schLessonsContainer.innerHTML = '
Загрузка занятий...
'; + + try { + const lessons = await api.get(`/api/users/lessons/${teacherId}`); + + if (!lessons || !Array.isArray(lessons) || lessons.length === 0) { + schLessonsContainer.innerHTML = '
У преподавателя пока нет занятий
'; + return; + } + + const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; + const lessonsByDay = {}; + lessons.forEach(l => { + if (!lessonsByDay[l.day]) lessonsByDay[l.day] = []; + lessonsByDay[l.day].push(l); + }); + Object.keys(lessonsByDay).forEach(day => { + lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time)); + }); + + let html = ''; + daysOrder.forEach(day => { + if (!lessonsByDay[day]) return; + html += `
${day}
`; + lessonsByDay[day].forEach(lesson => { + html += ` +
+
+ ${escapeHtml(lesson.groupName)} + ${escapeHtml(lesson.time)} +
+
+
${escapeHtml(lesson.subjectName)}
+
+ ${escapeHtml(lesson.typeLesson)} + ${escapeHtml(lesson.lessonFormat)} + ${escapeHtml(lesson.week)} + ${escapeHtml(lesson.classroomName)} +
+
+
`; + }); + }); + + schLessonsContainer.innerHTML = html; + } catch (e) { + schLessonsContainer.innerHTML = `
Ошибка загрузки: ${escapeHtml(e.message)}
`; + } + } + + // ===== При смене преподавателя — подгрузить его занятия ===== + schTeacherSelect.addEventListener('change', function () { + const teacherId = this.value; + if (teacherId) { + loadTeacherLessons(teacherId); + } else { + modalLessons.style.display = 'none'; + schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; + } + }); + + // ===== Открытие / закрытие оверлея ===== + function openOverlay() { + // Автозаполнение преподавателя из фильтра таблицы + const autoTeacherId = getFilteredTeacherId(); + if (autoTeacherId) { + schTeacherSelect.value = autoTeacherId; + loadTeacherLessons(autoTeacherId); + } + + overlay.classList.add('open'); + } + + function closeOverlay() { + overlay.classList.remove('open'); + resetForm(); + } + + function resetForm() { + addForm.reset(); + schTeacherSelect.value = ''; + schGroupSelect.value = ''; + schDisciplineSelect.value = ''; + schClassroomSelect.value = ''; + schDaySelect.value = ''; + schTypeSelect.value = ''; + schTimeSelect.innerHTML = ''; + schTimeSelect.disabled = true; + if (schWeekUpper) schWeekUpper.checked = false; + if (schWeekLower) schWeekLower.checked = false; + if (schFormatOffline) schFormatOffline.checked = true; + modalLessons.style.display = 'none'; + schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; + hideAlert('sch-add-alert'); + } + + btnAddLesson.addEventListener('click', openOverlay); + btnClose.addEventListener('click', closeOverlay); + + // Закрытие по клику на оверлей (мимо модалок) + overlay.addEventListener('click', (e) => { + if (e.target === overlay || e.target.classList.contains('cs-overlay-scroll')) { + closeOverlay(); + } + }); + + // Закрытие по Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && overlay.classList.contains('open')) { + closeOverlay(); + } + }); + + // ===== Отправка формы ===== + addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('sch-add-alert'); + + const teacherId = schTeacherSelect.value; + const groupId = schGroupSelect.value; + const subjectId = schDisciplineSelect.value; + const classroomId = schClassroomSelect.value; + const lessonType = schTypeSelect.value; + const dayOfWeek = schDaySelect.value; + const timeSlot = schTimeSelect.value; + const lessonFormat = document.querySelector('input[name="schLessonFormat"]:checked')?.value; + + if (!teacherId) { showAlert('sch-add-alert', 'Выберите преподавателя', 'error'); return; } + if (!groupId) { showAlert('sch-add-alert', 'Выберите группу', 'error'); return; } + if (!subjectId) { showAlert('sch-add-alert', 'Выберите дисциплину', 'error'); return; } + if (!classroomId) { showAlert('sch-add-alert', 'Выберите аудиторию', 'error'); return; } + if (!dayOfWeek) { showAlert('sch-add-alert', 'Выберите день недели', 'error'); return; } + if (!timeSlot) { showAlert('sch-add-alert', 'Выберите время', 'error'); return; } + + const weekUpperChecked = schWeekUpper?.checked || false; + const weekLowerChecked = schWeekLower?.checked || false; + + if (!weekUpperChecked && !weekLowerChecked) { + showAlert('sch-add-alert', 'Не выбран тип недели', 'error'); + return; + } + + let weekType = null; + if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; + else if (weekUpperChecked) weekType = 'Верхняя'; + else if (weekLowerChecked) weekType = 'Нижняя'; + + try { + await api.post('/api/users/lessons/create', { + teacherId: parseInt(teacherId), + groupId: parseInt(groupId), + subjectId: parseInt(subjectId), + classroomId: parseInt(classroomId), + typeLesson: lessonType, + lessonFormat: lessonFormat, + day: dayOfWeek, + week: weekType, + time: timeSlot + }); + + showAlert('sch-add-alert', 'Занятие добавлено ✓', 'success'); + + // Очистить все поля кроме преподавателя (для массового добавления) + schGroupSelect.selectedIndex = 0; + schDisciplineSelect.selectedIndex = 0; + schClassroomSelect.selectedIndex = 0; + schTypeSelect.selectedIndex = 0; + schDaySelect.selectedIndex = 0; + schTimeSelect.innerHTML = ''; + schTimeSelect.disabled = true; + schWeekUpper.checked = false; + schWeekLower.checked = false; + document.querySelector('input[name="schLessonFormat"][value="Очно"]').checked = true; + + // Обновить занятия преподавателя в модалке 2 + if (teacherId) { + await loadTeacherLessons(teacherId); + } + + // Обновить основную таблицу + await loadSchedule(); + + setTimeout(() => { + hideAlert('sch-add-alert'); + }, 4000); + } catch (err) { + showAlert('sch-add-alert', err.message || 'Ошибка добавления занятия', 'error'); + } + }); + + // ===================== Инициализация ===================== + await Promise.all([ + loadSchedule(), + loadGroups(), + loadSubjects(), + loadClassrooms(), + loadTeachers() + ]); } \ No newline at end of file diff --git a/frontend/admin/js/views/subjects.js b/frontend/admin/js/views/subjects.js index 2978419..c31abbe 100755 --- a/frontend/admin/js/views/subjects.js +++ b/frontend/admin/js/views/subjects.js @@ -24,19 +24,21 @@ export async function initSubjects() { renderSubjects(allSubjects); populateSubjectSelect(allSubjects); } catch (e) { - if (subjectsTbody) subjectsTbody.innerHTML = 'Ошибка загрузки'; + if (subjectsTbody) subjectsTbody.innerHTML = 'Ошибка загрузки'; } } function renderSubjects(subjects) { if (!subjects || !subjects.length) { - subjectsTbody.innerHTML = 'Нет дисциплин'; + subjectsTbody.innerHTML = 'Нет дисциплин'; return; } subjectsTbody.innerHTML = subjects.map(s => ` ${s.id} ${escapeHtml(s.name)} + ${escapeHtml(s.code || '-')} + ${s.departmentId || '-'} `).join(''); } @@ -100,11 +102,19 @@ export async function initSubjects() { e.preventDefault(); hideAlert('create-subject-alert'); const name = document.getElementById('new-subject-name').value.trim(); + const code = document.getElementById('new-subject-code').value.trim(); + const departmentId = document.getElementById('new-subject-department').value; if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; } + if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; } + if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; } try { - const data = await api.post('/api/subjects', { name }); - showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success'); + const data = await api.post('/api/subjects', { + name, + code, + departmentId: Number(departmentId) + }); + showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success'); createSubjectForm.reset(); loadSubjects(); } catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); } diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js index 5a02dcf..2189442 100755 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -7,7 +7,9 @@ const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'b export async function initUsers() { const usersTbody = document.getElementById('users-tbody'); const createForm = document.getElementById('create-form'); - const modalBackdrop = document.getElementById('modal-backdrop'); + + // ===== Оверлей (cs-overlay) ===== + const usersOverlay = document.getElementById('users-overlay'); // ===== 1-е модальное окно: Добавить занятие ===== const modalAddLesson = document.getElementById('modal-add-lesson'); @@ -28,7 +30,6 @@ export async function initUsers() { // ===== 2-е модальное окно: Просмотр занятий ===== const modalViewLessons = document.getElementById('modal-view-lessons'); - const modalViewLessonsClose = document.getElementById('modal-view-lessons-close'); const lessonsContainer = document.getElementById('lessons-container'); const modalTeacherName = document.getElementById('modal-teacher-name'); @@ -56,36 +57,6 @@ export async function initUsers() { "13:20-14:50" ]; - // ========================================================= - // СИНХРОНИЗАЦИЯ ВЫСОТЫ 1-й МОДАЛКИ -> CSS переменная - // ========================================================= - const addLessonContent = document.querySelector('#modal-add-lesson .modal-content'); - - function setAddLessonHeightVar(px) { - const h = Math.max(0, Math.ceil(px || 0)); - document.documentElement.style.setProperty('--add-lesson-height', `${h}px`); - } - - function syncAddLessonHeight() { - if (!addLessonContent) return; - - if (!modalAddLesson?.classList.contains('open')) { - // если первая модалка закрыта — "шапки" нет - setAddLessonHeightVar(0); - return; - } - - setAddLessonHeightVar(addLessonContent.getBoundingClientRect().height); - } - - // Авто-обновление при любом изменении размеров первой модалки - if (addLessonContent && 'ResizeObserver' in window) { - const ro = new ResizeObserver(() => syncAddLessonHeight()); - ro.observe(addLessonContent); - } - - window.addEventListener('resize', () => syncAddLessonHeight()); - // ========================================================= // Загрузка справочников // ========================================================= @@ -196,14 +167,14 @@ export async function initUsers() { renderUsers(users); } catch (e) { usersTbody.innerHTML = - 'Ошибка загрузки: ' + + 'Ошибка загрузки: ' + escapeHtml(e.message) + ''; } } function renderUsers(users) { if (!users || !users.length) { - usersTbody.innerHTML = 'Нет пользователей'; + usersTbody.innerHTML = 'Нет пользователей'; return; } @@ -211,6 +182,9 @@ export async function initUsers() { ${u.id} ${escapeHtml(u.username)} + ${escapeHtml(u.fullName || '-')} + ${escapeHtml(u.jobTitle || '-')} + ${u.departmentName || '-'} ${ROLE_LABELS[u.role] || escapeHtml(u.role)} @@ -222,25 +196,15 @@ export async function initUsers() { `).join(''); } - function updateBackdrop() { - if(!modalBackdrop) return; - const anyOpen = - modalAddLesson?.classList.contains('open') || - modalViewLessons?.classList.contains('open'); - - modalBackdrop.classList.toggle('open', anyOpen); + // ===== Открытие / закрытие оверлея ===== + function openOverlay() { + if (usersOverlay) usersOverlay.classList.add('open'); } - // Клик мимо модалок закроет их, если не надо, то закомментить этот код - modalBackdrop?.addEventListener('click', () => { - if (modalAddLesson?.classList.contains('open')) { - modalAddLesson.classList.remove('open'); + function closeOverlay() { + if (usersOverlay) usersOverlay.classList.remove('open'); + if (modalViewLessons) modalViewLessons.style.display = 'none'; resetLessonForm(); - syncAddLessonHeight(); - } - if (modalViewLessons?.classList.contains('open')) { - closeViewLessonsModal(); - } - }); + } // ========================================================= // 1-я модалка: добавление занятия @@ -267,9 +231,7 @@ export async function initUsers() { lessonDaySelect.value = ''; updateTimeOptions(''); - modalAddLesson.classList.add('open'); - updateBackdrop(); - requestAnimationFrame(() => syncAddLessonHeight()); + openOverlay(); } addLessonForm.addEventListener('submit', async (e) => { @@ -286,15 +248,20 @@ export async function initUsers() { const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value; - if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; } - if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; } - if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; } - if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; } - if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; } + if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; } + if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; } + if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; } + if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; } + if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); return; } const weekUpperChecked = weekUpper?.checked || false; const weekLowerChecked = weekLower?.checked || false; + if (!weekUpperChecked && !weekLowerChecked) { + showAlert('add-lesson-alert', 'Не выбран тип недели', 'error'); + return; + } + let weekType = null; if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; else if (weekUpperChecked) weekType = 'Верхняя'; @@ -313,57 +280,45 @@ export async function initUsers() { time: timeSlot }); - if (modalViewLessons?.classList.contains('open') && currentLessonsTeacherId == userId) { + if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) { await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName); } - showAlert('add-lesson-alert', 'Занятие добавлено', 'success'); + showAlert('add-lesson-alert', 'Занятие добавлено ✓', 'success'); - lessonGroupSelect.value = ''; - lessonDisciplineSelect.value = ''; - lessonClassroomSelect.value = ''; - lessonTypeSelect.value = ''; - lessonDaySelect.value = ''; - lessonTimeSelect.value = ''; + lessonGroupSelect.selectedIndex = 0; + lessonDisciplineSelect.selectedIndex = 0; + lessonClassroomSelect.selectedIndex = 0; + lessonTypeSelect.selectedIndex = 0; + lessonDaySelect.selectedIndex = 0; + lessonTimeSelect.innerHTML = ''; lessonTimeSelect.disabled = true; weekUpper.checked = false; weekLower.checked = false; document.querySelector('input[name="lessonFormat"][value="Очно"]').checked = true; - requestAnimationFrame(() => syncAddLessonHeight()); - setTimeout(() => { hideAlert('add-lesson-alert'); - syncAddLessonHeight(); }, 3000); } catch (err) { showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error'); - requestAnimationFrame(() => syncAddLessonHeight()); } }); lessonDaySelect.addEventListener('change', function () { updateTimeOptions(this.value); - requestAnimationFrame(() => syncAddLessonHeight()); }); if (modalAddLessonClose) { - modalAddLessonClose.addEventListener('click', () => { - modalAddLesson.classList.remove('open'); - resetLessonForm(); - syncAddLessonHeight(); - updateBackdrop(); - }); + modalAddLessonClose.addEventListener('click', () => closeOverlay()); } - if (modalAddLesson) { - modalAddLesson.addEventListener('click', (e) => { - if (e.target === modalAddLesson) { - modalAddLesson.classList.remove('open'); - resetLessonForm(); - syncAddLessonHeight(); - updateBackdrop(); + // Клик по оверлею (мимо модалок) закрывает всё + if (usersOverlay) { + usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => { + if (e.target.classList.contains('cs-overlay-scroll')) { + closeOverlay(); } }); } @@ -378,14 +333,24 @@ export async function initUsers() { const username = document.getElementById('new-username').value.trim(); const password = document.getElementById('new-password').value; const role = document.getElementById('new-role').value; + const fullName = document.getElementById('new-fullname').value.trim(); + const jobTitle = document.getElementById('new-jobtitle').value.trim(); + const departmentId = document.getElementById('new-department').value; - if (!username || !password) { + if (!username || !password || !fullName || !jobTitle || !departmentId) { showAlert('create-alert', 'Заполните все поля', 'error'); return; } try { - const data = await api.post('/api/users', { username, password, role }); + const data = await api.post('/api/users', { + username, + password, + role, + fullName, + jobTitle, + departmentId: Number(departmentId) + }); showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success'); createForm.reset(); loadUsers(); @@ -468,48 +433,20 @@ export async function initUsers() { currentLessonsTeacherId = teacherId; currentLessonsTeacherName = teacherName || ''; + if (modalViewLessons) modalViewLessons.style.display = ''; loadTeacherLessons(teacherId, teacherName); - - requestAnimationFrame(() => syncAddLessonHeight()); - - modalViewLessons.classList.add('open'); - updateBackdrop(); -// document.body.style.overflow = 'hidden'; } function closeViewLessonsModal() { - modalViewLessons.classList.remove('open'); - updateBackdrop(); -// document.body.style.overflow = ''; - + if (modalViewLessons) modalViewLessons.style.display = 'none'; currentLessonsTeacherId = null; currentLessonsTeacherName = ''; } - if (modalViewLessonsClose) { - modalViewLessonsClose.addEventListener('click', closeViewLessonsModal); - } - - if (modalViewLessons) { - modalViewLessons.addEventListener('click', (e) => { - if (e.target === modalViewLessons) closeViewLessonsModal(); - }); - } - document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; - - if (modalAddLesson?.classList.contains('open')) { - modalAddLesson.classList.remove('open'); - resetLessonForm(); - syncAddLessonHeight(); - updateBackdrop(); - return; - } - - if (modalViewLessons?.classList.contains('open')) { - closeViewLessonsModal(); - return; + if (usersOverlay?.classList.contains('open')) { + closeOverlay(); } }); diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css new file mode 100644 index 0000000..0c66136 --- /dev/null +++ b/frontend/admin/settings/css/layout.css @@ -0,0 +1,325 @@ +/* ===== Sidebar ===== */ +.sidebar { + width: 260px; + min-height: 100vh; + background: var(--bg-sidebar); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid var(--bg-card-border); + display: flex; + flex-direction: column; + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 1000; + transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.sidebar-header { + padding: 1.25rem; + border-bottom: 1px solid var(--bg-card-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.sidebar-close-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: var(--radius-sm); + transition: all var(--transition); +} + +.sidebar-close-btn:hover { + background: var(--bg-card-border); + color: var(--text-primary); +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.sidebar-nav { + flex: 1; + padding: 0.75rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 0.25rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.95rem; + font-weight: 500; + transition: all var(--transition); + position: relative; + overflow: hidden; +} + +.nav-item::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--accent); + border-radius: 0 4px 4px 0; + transform: scaleY(0); + transition: transform var(--transition); + opacity: 0; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: translateX(4px); +} + +.nav-item.active { + background: rgba(139, 92, 246, 0.12); + color: var(--accent-hover); +} + +[data-theme="light"] .nav-item.active { + background: rgba(99, 102, 241, 0.18); +} + +.nav-item.active::before { + transform: scaleY(1); + opacity: 1; +} + +.nav-item svg { + transition: transform var(--transition); +} + +.nav-item:hover svg, +.nav-item.active svg { + transform: scale(1.15) rotate(-5deg); +} + +.sidebar-footer { + padding: 0.75rem; + border-top: 1px solid var(--bg-card-border); +} + +.btn-back { + width: 100%; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.8rem; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + transition: background var(--transition), color var(--transition); + position: relative; +} + +.btn-back:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ===== Main ===== */ +.main { + flex: 1; + margin-left: 260px; + min-height: 100vh; + transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +/* Desktop Collapse State */ +@media (min-width: 769px) { + .sidebar.collapsed { + width: 74px; + } + + .sidebar.collapsed .logo span { + display: none; + } + + .sidebar.collapsed .nav-item span, + .sidebar.collapsed .btn-back span { + position: absolute; + left: calc(100% + 10px); + top: 50%; + transform: translateY(-50%) translateX(-10px); + background: rgba(10, 10, 15, 0.95); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + color: var(--text-primary); + padding: 0.5rem 0.8rem; + border-radius: var(--radius-sm); + border: 1px solid var(--bg-card-border); + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + + [data-theme="light"] .sidebar.collapsed .nav-item span, + [data-theme="light"] .sidebar.collapsed .btn-back span { + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + .sidebar.collapsed .nav-item:hover span, + .sidebar.collapsed .btn-back:hover span { + opacity: 1; + visibility: visible; + transform: translateY(-50%) translateX(0); + } + + .sidebar.collapsed .sidebar-close-btn { + transform: rotate(180deg); + } + + .sidebar.collapsed .logo { + justify-content: center; + padding: 0; + } + + .sidebar.collapsed .nav-item { + justify-content: center; + padding: 0.75rem 0; + overflow: visible; + } + + .sidebar.collapsed .btn-back { + justify-content: center; + padding: 0.65rem 0; + } + + .sidebar.collapsed .sidebar-header { + flex-direction: column; + gap: 1.5rem; + padding: 1.25rem 0; + } + + .main.sidebar-collapsed { + margin-left: 74px; + } + + .main.sidebar-collapsed .menu-toggle { + display: none; + } +} + +.topbar { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--bg-card-border); + transition: border-color 0.4s ease; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.topbar h1 { + font-size: 1.3rem; + font-weight: 700; + letter-spacing: -0.02em; + flex: 1; +} + +.content { + padding: 1.5rem 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + animation: fadeIn 0.2s ease; +} + +/* ===== Mobile Menu Toggle ===== */ +.menu-toggle { + display: none; + padding: 0.4rem; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--transition); +} + +.menu-toggle:hover { + background: var(--bg-hover); +} + +.sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + z-index: 9; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity var(--transition), visibility var(--transition); +} + +/* ===== Responsive Mobile ===== */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .main { + margin-left: 0; + } + + .topbar { + padding: 1rem 1.25rem; + } + + .content { + padding: 1.25rem; + } + + .menu-toggle, + .sidebar-overlay { + display: block; + } + + .sidebar-overlay.open { + opacity: 1; + visibility: visible; + pointer-events: auto; + } +} diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css new file mode 100644 index 0000000..f491eef --- /dev/null +++ b/frontend/admin/settings/css/main.css @@ -0,0 +1,153 @@ +/* ===== Reset & Base ===== */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0a0a0f; + --bg-sidebar: rgba(255, 255, 255, 0.02); + --bg-card: rgba(255, 255, 255, 0.03); + --bg-card-border: rgba(255, 255, 255, 0.05); + --bg-input: rgba(255, 255, 255, 0.04); + --bg-input-focus: rgba(255, 255, 255, 0.08); + --bg-hover: rgba(255, 255, 255, 0.06); + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-placeholder: #475569; + + --accent: #8b5cf6; + --accent-hover: #a78bfa; + --accent-glow: rgba(139, 92, 246, 0.4); + --accent-secondary: #ec4899; + + --error: #ef4444; + --success: #10b981; + --warning: #f59e0b; + + --radius-sm: 10px; + --radius-md: 16px; + --transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +/* ===== Light Theme ===== */ +[data-theme="light"] { + --bg-primary: #f8fafc; + --bg-sidebar: rgba(255, 255, 255, 0.7); + --bg-card: rgba(255, 255, 255, 0.75); + --bg-card-border: rgba(0, 0, 0, 0.08); + --bg-input: rgba(0, 0, 0, 0.03); + --bg-input-focus: rgba(0, 0, 0, 0.06); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #0f172a; + --text-secondary: #475569; + --text-placeholder: #94a3b8; + --accent: #6366f1; + --accent-hover: #4f46e5; + --accent-glow: rgba(99, 102, 241, 0.3); + --accent-secondary: #d946ef; + --error: #ef4444; + --success: #10b981; + --warning: #f59e0b; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + display: flex; + transition: background 0.4s ease, color 0.4s ease; +} + +/* ===== Animations ===== */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== Theme Toggle ===== */ +.theme-toggle { + width: 42px; + height: 42px; + border: none; + border-radius: 50%; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; + z-index: 100; + flex-shrink: 0; +} + +.theme-toggle svg { + width: 20px; + height: 20px; + transition: transform 0.4s ease; +} + +.theme-toggle:hover { + transform: scale(1.1); + box-shadow: 0 4px 16px var(--accent-glow); +} + +.theme-toggle:active { + transform: scale(0.95); +} + +.theme-toggle--fixed { + position: fixed; + top: 1.25rem; + right: 1.25rem; +} + +/* ===== Settings Placeholder ===== */ +.settings-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 4rem 2rem; + animation: fadeIn 0.4s ease both; +} + +.settings-placeholder .icon-wrap { + width: 80px; + height: 80px; + border-radius: 50%; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(236, 72, 153, 0.15)); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + box-shadow: 0 0 30px var(--accent-glow); +} + +.settings-placeholder h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.settings-placeholder p { + color: var(--text-secondary); + font-size: 0.95rem; + max-width: 400px; + line-height: 1.6; +} diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html new file mode 100644 index 0000000..398526a --- /dev/null +++ b/frontend/admin/settings/index.html @@ -0,0 +1,88 @@ + + + + + + + Настройки — Magistr + + + + + + + + + + + + + + + + +
+
+ +

Загрузка...

+
+ +
+ +
+
+ + + + + + diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js new file mode 100644 index 0000000..61a1ac3 --- /dev/null +++ b/frontend/admin/settings/js/main.js @@ -0,0 +1,105 @@ +// Settings page main.js +import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; + +// Auth check +const token = localStorage.getItem('token'); +const role = localStorage.getItem('role'); +if (!token || role !== 'ADMIN') { + window.location.href = '/'; +} + +// Global initialization for Custom Selects +document.addEventListener('DOMContentLoaded', () => { + initAllCustomDropdowns(document.body); + startDropdownAutoObserver(); +}); + +// Configuration +const ROUTES = { + general: { title: 'Общие настройки', file: 'views/general.html' }, +}; + +let currentTab = null; + +// DOM Elements +const appContent = document.getElementById('app-content'); +const pageTitle = document.getElementById('page-title'); +const navItems = document.querySelectorAll('.nav-item[data-tab]'); +const sidebar = document.querySelector('.sidebar'); +const sidebarOverlay = document.getElementById('sidebar-overlay'); +const menuToggle = document.getElementById('menu-toggle'); +const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); +const main = document.querySelector('.main'); + +// Init sidebar state from localStorage +if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { + sidebar.classList.add('collapsed'); + main.classList.add('sidebar-collapsed'); +} + +// Menu Toggle (Hamburger) +menuToggle.addEventListener('click', () => { + if (window.innerWidth <= 768) { + sidebar.classList.toggle('open'); + sidebarOverlay.classList.toggle('open'); + } else { + sidebar.classList.remove('collapsed'); + main.classList.remove('sidebar-collapsed'); + localStorage.setItem('sidebar-collapsed', 'false'); + } +}); + +// Sidebar Close (X button) +sidebarCloseBtn?.addEventListener('click', () => { + if (window.innerWidth <= 768) { + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('open'); + } else { + sidebar.classList.toggle('collapsed'); + main.classList.toggle('sidebar-collapsed'); + localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed')); + } +}); + +sidebarOverlay.addEventListener('click', () => { + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('open'); +}); + +// Navigation +navItems.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const tab = item.dataset.tab; + switchTab(tab); + }); +}); + +async function switchTab(tab) { + if (currentTab === tab || !ROUTES[tab]) return; + + navItems.forEach(n => n.classList.remove('active')); + document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active'); + pageTitle.textContent = ROUTES[tab].title; + + try { + appContent.innerHTML = '
Загрузка...
'; + const response = await fetch(ROUTES[tab].file); + if (!response.ok) throw new Error('Failed to load view'); + + const html = await response.text(); + appContent.innerHTML = html; + + currentTab = tab; + } catch (e) { + appContent.innerHTML = `
Ошибка загрузки: ${e.message}
`; + console.error(e); + } + + // Close mobile menu if open + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('open'); +} + +// Load default tab +switchTab('general'); diff --git a/frontend/admin/settings/views/general.html b/frontend/admin/settings/views/general.html new file mode 100644 index 0000000..bbbebd8 --- /dev/null +++ b/frontend/admin/settings/views/general.html @@ -0,0 +1,11 @@ +
+
+ + + + +
+

Общие настройки

+

Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.

+
diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html new file mode 100644 index 0000000..8671b26 --- /dev/null +++ b/frontend/admin/views/auditorium-workload.html @@ -0,0 +1,75 @@ +
+
+

Загруженность аудиторий

+
+ +
+
+ +
+
+ Выберите корпуса... + + + +
+ +
+
+ +
+ +
+
+ Выберите вместимость... + + + +
+ +
+
+ +
+ +
+
+ Выберите оборудование... + + + +
+ +
+
+ +
+ + +
+
+ + +
+ + + + + + + + + + +
+ Аудитория + Время +
+
+
diff --git a/frontend/admin/views/department.html b/frontend/admin/views/department.html new file mode 100644 index 0000000..11bf25b --- /dev/null +++ b/frontend/admin/views/department.html @@ -0,0 +1,180 @@ +
+
+

Запрос расписания кафедры

+ +
+
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+
+ + +
+
+ + + + + + + +
+
+ +
+ +
\ No newline at end of file diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html new file mode 100644 index 0000000..a7ae105 --- /dev/null +++ b/frontend/admin/views/departments-data.html @@ -0,0 +1,80 @@ + +
+

Создание кафедры

+
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Кафедры

+
+
+ + + + + + + + + + + + + +
IDНазваниеКод
Загрузка...
+
+
+ +
+

Создание специальности

+
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Специальности

+
+
+ + + + + + + + + + + + + +
IDНазваниеКод специальности
Загрузка...
+
+
diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 5944cdc..1bb9015 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -17,6 +17,18 @@ +
+ + +
+
+ + +
+
+ + +
@@ -41,12 +53,15 @@ Название Численность (чел.) Форма обучения + ID кафедры + Курс + Код специальности Действия - Загрузка... + Загрузка... diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 35d4701..8104356 100755 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,5 +1,8 @@
-

Расписание занятий

+
+

Расписание занятий

+ +
@@ -35,9 +38,142 @@ - +
Загрузка...Загрузка...
+
+ + +
+
+ + +
+
+

Добавить занятие

+ +
+ +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + + + +
\ No newline at end of file diff --git a/frontend/admin/views/subjects.html b/frontend/admin/views/subjects.html index a312430..167edaa 100755 --- a/frontend/admin/views/subjects.html +++ b/frontend/admin/views/subjects.html @@ -7,6 +7,14 @@ +
+ + +
+
+ + +
@@ -43,12 +51,14 @@ ID Название + Код предмета + Кафедра (ID) Действия - Загрузка... + Загрузка... diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index 84e6815..bf282f3 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -1,172 +1,189 @@ - -
-

Новый пользователь

-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-

Все пользователи

-
- - - - - - - - - - - - - - -
IDИмя пользователяРольДействия
Загрузка...
-
-
- - - - \ No newline at end of file + +
+

Новый пользователь

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+

Все пользователи

+
+ + + + + + + + + + + + + + + + + +
IDИмя пользователяФИОДолжностьКафедраРольДействия
Загрузка...
+
+
+ + +
+
+ + + + + + + +
+
\ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index 6603b09..80bdf31 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,6 +1,34 @@ (() => { 'use strict'; + // --- OpenTelemetry Frontend Instrumentation --- + // Загружаем OTel только на продакшене (не на localhost) + if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { + import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => { + const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http'); + const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web'); + const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation'); + const { Resource } = await import('https://esm.sh/@opentelemetry/resources'); + + const exporter = new OTLPTraceExporter({ + url: window.location.origin + '/otel/v1/traces' + }); + + const provider = new WebTracerProvider({ + resource: new Resource({ 'service.name': 'magistr-frontend' }), + }); + + provider.addSpanProcessor(new BatchSpanProcessor(exporter)); + provider.register(); + + registerInstrumentations({ + instrumentations: [getWebAutoInstrumentations()] + }); + console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде."); + }).catch(e => console.error("Ошибка загрузки OTel:", e)); + } + // ---------------------------------------------- + const form = document.getElementById('login-form'); const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); @@ -115,6 +143,7 @@ if (data.token) localStorage.setItem('token', data.token); if (data.role) localStorage.setItem('role', data.role); + if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); const redirect = data.redirect || '/'; setTimeout(() => { window.location.href = redirect; }, 400);