From c10198515c7426421abe2f78e3b6196b3dc56cb1 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Wed, 18 Mar 2026 15:39:11 +0300 Subject: [PATCH 01/45] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V2__departmentCreate.sql | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V2__departmentCreate.sql diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql new file mode 100644 index 0000000..f584dfd --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -0,0 +1,104 @@ +-- =============================== +-- Создание таблицы кафедр +-- =============================== + +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'); + +-- =============================== +-- Обновление таблицы дисциплин +-- =============================== + +ALTER TABLE subjects +ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); + +UPDATE subjects +SET department_id = 1 +WHERE department_id IS NULL; + +ALTER TABLE subjects +ALTER COLUMN department_id SET NOT NULL; + +-- =============================== +-- Обновление таблицы групп +-- =============================== + +ALTER TABLE student_groups +ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); + +UPDATE student_groups +SET department_id = 1 +WHERE department_id IS NULL; + +ALTER TABLE student_groups +ALTER COLUMN department_id SET NOT NULL; + +-- =============================== +-- Обновление таблицы пользователей +-- =============================== + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), +ADD COLUMN IF NOT EXISTS job_title VARCHAR(255), +ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); + +UPDATE users +SET (full_name, job_title, department_id) = + ('Иванов Админ Иванович', 'Доцент', 1) +WHERE id = 1; + +UPDATE users +SET (full_name, job_title, department_id) = + ('Петров Препод Петрович', 'Профессор', 2) +WHERE id = 2; + +ALTER TABLE users +ALTER COLUMN full_name SET NOT NULL, +ALTER COLUMN job_title SET NOT NULL, +ALTER COLUMN department_id SET NOT NULL; + +-- =============================== +-- Создание таблицы данных расписания +-- =============================== + +CREATE TABLE IF NOT EXISTS schedule_data ( + id BIGSERIAL PRIMARY KEY, + department_id BIGINT NOT NULL REFERENCES departments(id), + semester INT NOT NULL, + group_id BIGINT NOT NULL REFERENCES student_groups(id), + subjects_id BIGINT NOT NULL REFERENCES subjects(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + number_of_hours INT NOT NULL, + is_division BOOLEAN DEFAULT NOT NULL FALSE, + teacher_id BIGINT NOT NULL REFERENCES users(id), + semester_type VARCHAR(255) NOT NULL, + period VARCHAR(255) NOT NULL +); + +INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) +VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'), + (2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'), + (3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024'); \ No newline at end of file From 220b99594f3eb46c43eb4870e61857c199818439 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Wed, 18 Mar 2026 15:44:05 +0300 Subject: [PATCH 02/45] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/db/migration/V2__departmentCreate.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index f584dfd..8f0e83c 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -92,7 +92,7 @@ CREATE TABLE IF NOT EXISTS schedule_data ( 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 DEFAULT NOT NULL FALSE, + is_division BOOLEAN DEFAULT NOT NULL DEFAULT FALSE, teacher_id BIGINT NOT NULL REFERENCES users(id), semester_type VARCHAR(255) NOT NULL, period VARCHAR(255) NOT NULL From 59b6704be92b616c5281d09d6366f7b858067f28 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 18 Mar 2026 20:05:30 +0300 Subject: [PATCH 03/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BA=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V2__departmentCreate.sql | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index 8f0e83c..c46c3cb 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -13,6 +13,7 @@ INSERT INTO departments (name, code) VALUES ('Кафедра ВТ', 2), ('Кафедра КТ', 3); +COMMENT ON TABLE departments IS 'Кафедры'; -- =============================== -- Создание таблицы специальностей -- =============================== @@ -28,6 +29,7 @@ INSERT INTO specialties (name, specialty_code) VALUES ('Информатика и вычислительная техника', '09.03.01'), ('Программная инженерия', '09.03.04'); +COMMENT ON TABLE specialties IS 'Специальности'; -- =============================== -- Обновление таблицы дисциплин -- =============================== @@ -42,6 +44,7 @@ WHERE department_id IS NULL; ALTER TABLE subjects ALTER COLUMN department_id SET NOT NULL; +COMMENT ON TABLE subjects IS 'Дисциплины'; -- =============================== -- Обновление таблицы групп -- =============================== @@ -56,6 +59,7 @@ WHERE department_id IS NULL; ALTER TABLE student_groups ALTER COLUMN department_id SET NOT NULL; +COMMENT ON TABLE student_groups IS 'Группы'; -- =============================== -- Обновление таблицы пользователей -- =============================== @@ -80,6 +84,7 @@ ALTER COLUMN full_name SET NOT NULL, ALTER COLUMN job_title SET NOT NULL, ALTER COLUMN department_id SET NOT NULL; +COMMENT ON TABLE users IS 'Пользователи'; -- =============================== -- Создание таблицы данных расписания -- =============================== @@ -101,4 +106,26 @@ CREATE TABLE IF NOT EXISTS schedule_data ( INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'), (2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'), - (3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024'); \ No newline at end of file + (3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024'); + +COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; +COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; +COMMENT ON COLUMN schedule_data.semester IS 'Номер семестра'; +COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; +COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; +COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; +COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; +COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; +COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; +COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; +COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; + +COMMENT ON TABLE education_forms IS 'Формы обучения'; +COMMENT ON TABLE subgroups IS 'Подгруппы'; +COMMENT ON TABLE lesson_types IS 'Типы занятий'; +COMMENT ON TABLE equipments IS 'Оборудование'; +COMMENT ON TABLE classrooms IS 'Аудитории'; +COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям'; +COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам'; +COMMENT ON TABLE equipments IS 'Оборудование'; +COMMENT ON TABLE equipments IS 'Оборудование'; \ No newline at end of file From 18d099460d3d7c20b1c24fe354d19ed804412c87 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 18 Mar 2026 20:16:34 +0300 Subject: [PATCH 04/45] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/db/migration/V2__departmentCreate.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index c46c3cb..753cebc 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS schedule_data ( 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 DEFAULT NOT NULL DEFAULT FALSE, + 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 From 7fac9f744d5c6855164ced97c83a8b86272678ac Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 18 Mar 2026 20:45:39 +0300 Subject: [PATCH 05/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V2__departmentCreate.sql | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index 753cebc..1a5bb81 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -128,4 +128,75 @@ COMMENT ON TABLE classrooms IS 'Аудитории'; COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям'; COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам'; COMMENT ON TABLE equipments IS 'Оборудование'; -COMMENT ON TABLE equipments IS 'Оборудование'; \ No newline at end of file +COMMENT ON TABLE equipments 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 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.course IS 'Курс'; +COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; +COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа'; +COMMENT ON COLUMN subgroups.name IS 'Название подгруппы'; +COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе'; + +COMMENT ON COLUMN subjects.id IS 'ID предмета'; +COMMENT ON COLUMN subjects.name IS 'Название предмета'; +COMMENT ON COLUMN subjects.code IS 'Код предмета'; +COMMENT ON COLUMN subjects.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 'Дата и время создания'; +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 'Время урока'; \ No newline at end of file From f519650bbbc68dbb74a7c249c1cee71b7df8db18 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 18 Mar 2026 20:48:36 +0300 Subject: [PATCH 06/45] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/db/migration/V2__departmentCreate.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index 1a5bb81..2dd3aba 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -178,7 +178,7 @@ 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 classrooms.created_at IS 'Дата и время создания'; COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; From 8ced8ae66900b3c9faaf93518d61b74d6bb4463c Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 19 Mar 2026 03:55:22 +0300 Subject: [PATCH 07/45] feat: Integrate OpenTelemetry for distributed tracing in both frontend and backend applications. --- backend/Dockerfile | 4 ++- backend/pom.xml | 7 +++++ .../app/config/tenant/TenantInterceptor.java | 7 +++++ .../db/migration/V2__departmentCreate.sql | 27 ++++++++++++++++--- frontend/script.js | 26 ++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) 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/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/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql index 2dd3aba..51a41fb 100644 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ b/backend/src/main/resources/db/migration/V2__departmentCreate.sql @@ -127,8 +127,7 @@ 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 equipments IS 'Оборудование'; -COMMENT ON TABLE equipments IS 'Оборудование'; +COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя'; COMMENT ON COLUMN users.id IS 'ID пользователя'; @@ -199,4 +198,26 @@ 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 'Время урока'; \ No newline at end of file +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 типа занятия'; + +COMMENT ON COLUMN schedule_data.id IS 'ID записи данных расписания'; + +COMMENT ON COLUMN subjects.department_id IS 'ID кафедры'; + +COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; + +COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; +COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; +COMMENT ON COLUMN users.department_id IS 'ID кафедры'; \ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index 6603b09..d427f1e 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,6 +1,32 @@ (() => { 'use strict'; + // --- OpenTelemetry Frontend Instrumentation --- + // Загружаем OTel Web SDK динамически через esm.sh, чтобы не ломать старый Vanilla JS (без type="module") + 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' // Трафик пойдет через ваш Caddy Proxy + }); + + 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'); From dc1c343174363d6dbfa118dc708094df31b7b5d6 Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 19 Mar 2026 04:30:31 +0300 Subject: [PATCH 08/45] Fix database migration: merge V2 into V1 and remove V2 --- .../main/resources/db/migration/V1__init.sql | 193 ++++++++++++++++-- 1 file changed, 175 insertions(+), 18 deletions(-) diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 3c3339b..dd59492 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,15 @@ 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), + department_id BIGINT NOT NULL REFERENCES departments(id), course INT CHECK (course BETWEEN 1 AND 6), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Тестовая базовая группа для работы -INSERT INTO student_groups (name, group_size, education_form_id, course) -VALUES ('ИВТ-21-1', 25, 1, 3), - ('ИБ-41м', 15, 2, 2) +INSERT INTO student_groups (name, group_size, education_form_id, department_id, course) +VALUES ('ИВТ-21-1', 25, 1, 1, 3), + ('ИБ-41м', 15, 2, 1, 2) ON CONFLICT (name) DO NOTHING; -- ========================================== @@ -75,23 +104,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 +175,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 +193,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 +201,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 +232,28 @@ 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), + semester INT NOT NULL, + group_id BIGINT NOT NULL REFERENCES student_groups(id), + subjects_id BIGINT NOT NULL REFERENCES subjects(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + number_of_hours INT NOT NULL, + is_division BOOLEAN NOT NULL DEFAULT FALSE, + teacher_id BIGINT NOT NULL REFERENCES users(id), + semester_type VARCHAR(255) NOT NULL, + period VARCHAR(255) NOT NULL +); + +INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) +VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'), + (2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'), + (3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024'); + -- ========================================== -- Функция обновления timestamp -- ========================================== @@ -216,7 +265,6 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Триггеры для обновления updated_at CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW @@ -226,4 +274,113 @@ 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.semester IS 'Номер семестра'; +COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; +COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; +COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; +COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; +COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; +COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; +COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; +COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; + +COMMENT ON TABLE education_forms IS 'Формы обучения'; +COMMENT ON TABLE subgroups IS 'Подгруппы'; +COMMENT ON TABLE lesson_types IS 'Типы занятий'; +COMMENT ON TABLE equipments IS 'Оборудование'; +COMMENT ON TABLE classrooms IS 'Аудитории'; +COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям'; +COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам'; +COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя'; + +COMMENT ON COLUMN users.id IS 'ID пользователя'; +COMMENT ON COLUMN users.username IS 'Логин пользователя'; +COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя'; +COMMENT ON COLUMN users.role IS 'Роль пользователя'; +COMMENT ON COLUMN users.created_at IS 'Дата и время создания'; +COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления'; +COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; +COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; +COMMENT ON COLUMN users.department_id IS 'ID кафедры'; + +COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; +COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; +COMMENT ON COLUMN education_forms.description IS 'Описание'; +COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN student_groups.id IS 'ID учебной группы'; +COMMENT ON COLUMN student_groups.name IS 'Название группы'; +COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; +COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; +COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; +COMMENT ON COLUMN student_groups.course IS 'Курс'; +COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; +COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа'; +COMMENT ON COLUMN subgroups.name IS 'Название подгруппы'; +COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе'; + +COMMENT ON COLUMN subjects.id IS 'ID предмета'; +COMMENT ON COLUMN subjects.name IS 'Название предмета'; +COMMENT ON COLUMN subjects.code IS 'Код предмета'; +COMMENT ON COLUMN subjects.department_id IS 'ID кафедры'; +COMMENT ON COLUMN subjects.description IS 'Описание предмета'; +COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN lesson_types.id IS 'ID урока'; +COMMENT ON COLUMN lesson_types.name IS 'Название типа урока'; +COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока'; +COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах'; + +COMMENT ON COLUMN equipments.id IS 'ID оборудования'; +COMMENT ON COLUMN equipments.name IS 'Название оборудования'; +COMMENT ON COLUMN equipments.description IS 'Описание оборудования'; +COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования'; + +COMMENT ON COLUMN classrooms.id IS 'ID аудитории'; +COMMENT ON COLUMN classrooms.name IS 'Название аудитории'; +COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории'; +COMMENT ON COLUMN classrooms.building IS 'Корпус'; +COMMENT ON COLUMN classrooms.floor IS 'Этаж'; +COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории'; +COMMENT ON COLUMN classrooms.description IS 'Описание аудитории'; +COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания'; + +COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; +COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; +COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2 +COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи'; + +COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя'; +COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета'; +COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя'; +COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания'; + +COMMENT ON COLUMN lessons.id IS 'ID урока'; +COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок'; +COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок'; +COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается'; +COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока'; +COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока'; +COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок'; +COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок'; +COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок'; +COMMENT ON COLUMN lessons.time IS 'Время урока'; + +COMMENT ON COLUMN departments.id IS 'ID кафедры'; +COMMENT ON COLUMN departments.name IS 'Название кафедры'; +COMMENT ON COLUMN departments.code IS 'Код кафедры'; + +COMMENT ON COLUMN specialties.id IS 'ID специальности'; +COMMENT ON COLUMN specialties.name IS 'Название специальности'; +COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности'; + +COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; +COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; +COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; \ No newline at end of file From 8cf086d3e90cd3cba710a9e210e3c5c72b2bfed2 Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 19 Mar 2026 04:37:11 +0300 Subject: [PATCH 09/45] Remove legacy V2 migration --- .../db/migration/V2__departmentCreate.sql | 223 ------------------ 1 file changed, 223 deletions(-) delete mode 100644 backend/src/main/resources/db/migration/V2__departmentCreate.sql diff --git a/backend/src/main/resources/db/migration/V2__departmentCreate.sql b/backend/src/main/resources/db/migration/V2__departmentCreate.sql deleted file mode 100644 index 51a41fb..0000000 --- a/backend/src/main/resources/db/migration/V2__departmentCreate.sql +++ /dev/null @@ -1,223 +0,0 @@ --- =============================== --- Создание таблицы кафедр --- =============================== - -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); - -COMMENT ON TABLE departments IS 'Кафедры'; --- =============================== --- Создание таблицы специальностей --- =============================== - -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'); - -COMMENT ON TABLE specialties IS 'Специальности'; --- =============================== --- Обновление таблицы дисциплин --- =============================== - -ALTER TABLE subjects -ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); - -UPDATE subjects -SET department_id = 1 -WHERE department_id IS NULL; - -ALTER TABLE subjects -ALTER COLUMN department_id SET NOT NULL; - -COMMENT ON TABLE subjects IS 'Дисциплины'; --- =============================== --- Обновление таблицы групп --- =============================== - -ALTER TABLE student_groups -ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); - -UPDATE student_groups -SET department_id = 1 -WHERE department_id IS NULL; - -ALTER TABLE student_groups -ALTER COLUMN department_id SET NOT NULL; - -COMMENT ON TABLE student_groups IS 'Группы'; --- =============================== --- Обновление таблицы пользователей --- =============================== - -ALTER TABLE users -ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), -ADD COLUMN IF NOT EXISTS job_title VARCHAR(255), -ADD COLUMN IF NOT EXISTS department_id BIGINT REFERENCES departments(id); - -UPDATE users -SET (full_name, job_title, department_id) = - ('Иванов Админ Иванович', 'Доцент', 1) -WHERE id = 1; - -UPDATE users -SET (full_name, job_title, department_id) = - ('Петров Препод Петрович', 'Профессор', 2) -WHERE id = 2; - -ALTER TABLE users -ALTER COLUMN full_name SET NOT NULL, -ALTER COLUMN job_title SET NOT NULL, -ALTER COLUMN department_id SET NOT NULL; - -COMMENT ON TABLE users IS 'Пользователи'; --- =============================== --- Создание таблицы данных расписания --- =============================== - -CREATE TABLE IF NOT EXISTS schedule_data ( - id BIGSERIAL PRIMARY KEY, - department_id BIGINT NOT NULL REFERENCES departments(id), - semester INT NOT NULL, - group_id BIGINT NOT NULL REFERENCES student_groups(id), - subjects_id BIGINT NOT NULL REFERENCES subjects(id), - lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), - number_of_hours INT NOT NULL, - is_division BOOLEAN NOT NULL DEFAULT FALSE, - teacher_id BIGINT NOT NULL REFERENCES users(id), - semester_type VARCHAR(255) NOT NULL, - period VARCHAR(255) NOT NULL -); - -INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) -VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'), - (2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'), - (3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024'); - -COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; -COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; -COMMENT ON COLUMN schedule_data.semester IS 'Номер семестра'; -COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; -COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; -COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; -COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; -COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; -COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; -COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; -COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; - -COMMENT ON TABLE education_forms IS 'Формы обучения'; -COMMENT ON TABLE subgroups IS 'Подгруппы'; -COMMENT ON TABLE lesson_types IS 'Типы занятий'; -COMMENT ON TABLE equipments IS 'Оборудование'; -COMMENT ON TABLE classrooms IS 'Аудитории'; -COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям'; -COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам'; -COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя'; - - -COMMENT ON COLUMN users.id IS 'ID пользователя'; -COMMENT ON COLUMN users.username IS 'Логин пользователя'; -COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя'; -COMMENT ON COLUMN users.role IS 'Роль пользователя'; -COMMENT ON COLUMN users.created_at IS 'Дата и время создания'; -COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления'; - -COMMENT ON COLUMN 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.course IS 'Курс'; -COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; - -COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; -COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа'; -COMMENT ON COLUMN subgroups.name IS 'Название подгруппы'; -COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе'; - -COMMENT ON COLUMN subjects.id IS 'ID предмета'; -COMMENT ON COLUMN subjects.name IS 'Название предмета'; -COMMENT ON COLUMN subjects.code IS 'Код предмета'; -COMMENT ON COLUMN subjects.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 'Дата и время создания'; -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 типа занятия'; - -COMMENT ON COLUMN schedule_data.id IS 'ID записи данных расписания'; - -COMMENT ON COLUMN subjects.department_id IS 'ID кафедры'; - -COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; - -COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; -COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; -COMMENT ON COLUMN users.department_id IS 'ID кафедры'; \ No newline at end of file From d78e675a71c604d8dc787ec9bae29e0d31883e8c Mon Sep 17 00:00:00 2001 From: alekan Date: Thu, 19 Mar 2026 23:42:09 +0300 Subject: [PATCH 10/45] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8=D0=BD=20=D0=B2?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5+=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=BA=D0=B0=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D0=B0=D0=BC=D0=B8(=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=B0=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/admin/css/department.css | 235 ++++++++++++++++++++++++++ frontend/admin/index.html | 12 ++ frontend/admin/js/main.js | 2 + frontend/admin/js/views/department.js | 4 + frontend/admin/js/views/subjects.js | 18 +- frontend/admin/views/department.html | 193 +++++++++++++++++++++ frontend/admin/views/subjects.html | 12 +- 7 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 frontend/admin/css/department.css create mode 100644 frontend/admin/js/views/department.js create mode 100644 frontend/admin/views/department.html diff --git a/frontend/admin/css/department.css b/frontend/admin/css/department.css new file mode 100644 index 0000000..691860e --- /dev/null +++ b/frontend/admin/css/department.css @@ -0,0 +1,235 @@ +.wrap{ + max-width: 900px; + margin: 0 auto; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 6px 20px rgba(0,0,0,.06); +} + +.header{ + padding: 14px 16px; + border-bottom: 1px solid var(--bg-card-border); + font-weight: 700; + color: var(--text-primary); +} + +details.table-item{ + border-top: 1px solid var(--bg-card-border); +} +details.table-item:first-of-type{ border-top:none; } + +summary{ + list-style: none; + cursor: pointer; + user-select: none; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 10px; +} +summary::-webkit-details-marker{ display:none; } + +.chev{ + width: 28px; + height: 28px; + border: 1px solid var(--bg-card-border); + border-radius: 10px; + display: grid; + place-items: center; + flex: 0 0 auto; + + color: var(--text-secondary); + background: var(--bg-input); + + transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; +} + +.chev-icon{ + width: 16px; + height: 16px; + display: block; +} + +summary:hover .chev{ + background: var(--bg-hover); + border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); + color: var(--text-primary); +} + +details[open] .chev{ + transform: rotate(180deg); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); +} + +.meta{ color: var(--text-secondary); font-size: 12px; } + +.content{ padding: 0 16px 16px 16px; } + +.wrap table{ + width: 100%; + border-collapse: collapse; + border: 1px solid var(--bg-card-border); + border-radius: 10px; + overflow: hidden; + background: var(--bg-card); +} + +.wrap thead th{ + text-align: left; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-input); + border-bottom: 1px solid var(--bg-card-border); + padding: 10px 12px; +} + +.wrap tbody td{ + padding: 10px 12px; + border-bottom: 1px solid var(--bg-card-border); + font-size: 14px; + color: var(--text-primary); +} + +.wrap tbody tr:hover{ background: var(--bg-hover); } + +.title-multiline{ + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.2; +} + +.title-multiline .title-main{ + font-weight: 700; + color: var(--text-primary); +} + +.title-multiline .title-sub{ + font-weight: 500; + font-size: 12px; + color: var(--text-secondary); +} + +.title-multiline b{ + font-weight: 700; + color: var(--text-primary); +} + +/* summary = 3 колонки: [chev] [title] [meta] */ +details.table-item > summary{ + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 12px; + align-items: start; /* важно: всё прижимаем к верху */ + padding: 12px 16px; +} + +/* чтобы текст нормально переносился и не растягивал мету */ +details.table-item > summary .title{ + min-width: 0; /* важно для grid, иначе может распирать */ +} + +/* "2 записи" всегда справа и сверху, аккуратно */ +details.table-item > summary .meta{ + justify-self: end; + align-self: start; + white-space: nowrap; + padding-top: 4px; /* чуть опустить относительно первой строки */ + font-size: 12px; + color: var(--text-secondary); +} + +/* стрелка тоже сверху */ +details.table-item > summary .chev{ + align-self: start; + margin-top: 2px; +} + +.records-search{ + width: min(360px, 60vw); + padding: 0.45rem 0.7rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; + outline: none; + transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; +} + +.records-search::placeholder{ color: var(--text-placeholder); } + +.records-search:focus{ + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} +/* Таблица внутри раскрывающегося блока */ +details.table-item .content table{ + width: 100%; + border-collapse: separate; /* нужно для красивых линий */ + border-spacing: 0; + border: 1px solid var(--bg-card-border); + border-radius: 12px; + overflow: hidden; + background: var(--bg-card); +} + +/* Шапка */ +details.table-item .content thead th{ + position: sticky; /* опционально: шапка прилипает при скролле */ + top: 0; + z-index: 1; + + background: var(--bg-input); + color: var(--text-secondary); + border-bottom: 1px solid var(--bg-card-border); +} + +/* Ячейки: одинаковые отступы */ +details.table-item .content th, +details.table-item .content td{ + padding: 0.75rem 0.85rem; + vertical-align: top; +} + +/* Вертикальные разделители между колонками */ +details.table-item .content th:not(:last-child), +details.table-item .content td:not(:last-child){ + border-right: 1px solid var(--bg-card-border); +} + +/* Горизонтальные разделители между строками */ +details.table-item .content tbody td{ + border-bottom: 1px solid var(--bg-card-border); + color: var(--text-primary); +} + +/* У последней строки нет нижней линии */ +details.table-item .content tbody tr:last-child td{ + border-bottom: none; +} + +/* "Зебра" для читабельности */ +details.table-item .content tbody tr:nth-child(even){ + background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); +} + +/* Ховер по строке */ +details.table-item .content tbody tr:hover{ + background: var(--bg-hover); +} + +/* (Опционально) Чтобы длинный текст не ломал ширину */ +details.table-item .content td{ + word-break: break-word; +} + +/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ +details.table-item .content{ + overflow-x: auto; +} \ No newline at end of file diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 0bbddc0..7ddec41 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -14,6 +14,7 @@ + @@ -46,6 +47,17 @@ Пользователи + + + + + + + + + Кафедра + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 235203e..6745859 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -9,6 +9,7 @@ import { initClassrooms } from './views/classrooms.js'; import { initSubjects } from './views/subjects.js'; import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; +import {initDepartment} from "./views/department.js"; // Configuration const ROUTES = { @@ -20,6 +21,7 @@ const ROUTES = { subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects }, schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, + department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment }, }; let currentTab = null; diff --git a/frontend/admin/js/views/department.js b/frontend/admin/js/views/department.js new file mode 100644 index 0000000..b7c3711 --- /dev/null +++ b/frontend/admin/js/views/department.js @@ -0,0 +1,4 @@ +import { api } from '../api.js'; +import { escapeHtml } from '../utils.js'; + +export async function initDepartment() { } \ 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/views/department.html b/frontend/admin/views/department.html new file mode 100644 index 0000000..74dc1bf --- /dev/null +++ b/frontend/admin/views/department.html @@ -0,0 +1,193 @@ +
+

Кафедра

+ +
+ + + +
+
+ + +
+ + +
+ + +
+ Данные к составлению расписания + Кафедра: Информационная безопасность + Факультет: ФиПИ + Семестр: весенний + Уч. год: 2024/2025 +
+
3 записи
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
09.02.072 курс, 4 семестрИС-21Базы данныхЛабораторная2ДаИванов
09.02.072 курс, 4 семестрИС-22Операционные системыПрактика1НетСмирнов
09.02.071 курс, 2 семестрИС-12АлгоритмыЛекция2НетКузнецов
+
+
+ + +
+ + +
orders
+
1 запись
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
38.02.011 курс, 1 семестрЭК-11ЭкономикаЛекция1НетПетров
+
+
+ + +
+ + +
products
+
2 записи
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
15.02.083 курс, 6 семестрМС-31МатериаловедениеПрактика3ДаСидоров
15.02.083 курс, 6 семестрМС-32Технология металловЛабораторная2ДаОрлов
+
+
+ +
+ \ 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) Действия - Загрузка... + Загрузка... From f7fb524bb00333aff7c684d49c7933fea688f989 Mon Sep 17 00:00:00 2001 From: alekan Date: Fri, 20 Mar 2026 00:27:01 +0300 Subject: [PATCH 11/45] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=BE=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/admin/js/views/groups.js | 22 +++++++++++++++++----- frontend/admin/views/groups.html | 12 +++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 17301b5..cd23644 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,8 @@ export async function initGroups() { ${escapeHtml(g.name)} ${escapeHtml(g.groupSize)} ${escapeHtml(g.educationFormName)} + ${g.departmentId || '-'} + ${g.course || '-'} `).join(''); } @@ -80,14 +82,24 @@ 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; 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; } 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) + }); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); createGroupForm.reset(); loadGroups(); } catch (e) { diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 5944cdc..ae7880b 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -17,6 +17,14 @@ +
+ + +
+
+ + +
@@ -41,12 +49,14 @@ Название Численность (чел.) Форма обучения + ID кафедры + Курс Действия - Загрузка... + Загрузка... From 6774ebb766e1d3e635bb2a8ea83e12e71af85def Mon Sep 17 00:00:00 2001 From: alekan Date: Fri, 20 Mar 2026 00:33:27 +0300 Subject: [PATCH 12/45] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/admin/js/views/users.js | 21 +++++++++++++++++---- frontend/admin/views/users.html | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js index 5a02dcf..3b11350 100755 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -196,14 +196,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 +211,9 @@ export async function initUsers() { ${u.id} ${escapeHtml(u.username)} + ${escapeHtml(u.fullName || '-')} + ${escapeHtml(u.jobTitle || '-')} + ${u.departmentId || '-'} ${ROLE_LABELS[u.role] || escapeHtml(u.role)} @@ -378,14 +381,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(); diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index 84e6815..3b63477 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -19,6 +19,18 @@ +
+ + +
+
+ + +
+
+ + +
@@ -33,13 +45,16 @@ ID Имя пользователя + ФИО + Должность + ID кафедры Роль - Действия + Действия - Загрузка... + Загрузка... From b89d1c7f724c51c4c19b15ca82c3a957b32121b5 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Sat, 21 Mar 2026 00:20:34 +0300 Subject: [PATCH 13/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=20(fullName,=20jobTitle,=20departmentI?= =?UTF-8?q?d)=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9.=20=D0=94=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20Use?= =?UTF-8?q?rController.java?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/UserController.java | 132 ++++++++++++++---- .../magistr/app/dto/CreateUserRequest.java | 27 ++++ .../com/magistr/app/dto/UserResponse.java | 32 ++++- .../main/java/com/magistr/app/model/User.java | 33 +++++ 4 files changed, 196 insertions(+), 28 deletions(-) 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..0f97426 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -5,6 +5,10 @@ import com.magistr.app.dto.UserResponse; import com.magistr.app.model.Role; import com.magistr.app.model.User; 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,6 +20,7 @@ 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 BCryptPasswordEncoder passwordEncoder; @@ -26,52 +31,125 @@ public class UserController { @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 -> new UserResponse( + u.getId(), + u.getUsername(), + u.getRole().name(), + u.getFullName(), + u.getJobTitle(), + u.getDepartmentId() + )) + .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 -> new UserResponse( + u.getId(), + u.getUsername(), + u.getRole().name(), + u.getFullName(), + u.getJobTitle(), + u.getDepartmentId() + )) + .toList(); + logger.info("Получено {} преподавателей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e); + throw e; + } } @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/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/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index b1d3cdb..d4d30de 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -5,14 +5,20 @@ public class UserResponse { private Long id; private String username; private String role; + private String fullName; + private String jobTitle; + 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, Long departmentId) { this.id = id; this.username = username; this.role = role; + this.fullName = fullName; + this.jobTitle = jobTitle; + this.departmentId = departmentId; } public Long getId() { @@ -38,4 +44,28 @@ public class UserResponse { 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/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; + } } From c07e49ca98758df3a3365f7ec5e1d5d42c5590f1 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Sat, 21 Mar 2026 10:58:27 +0300 Subject: [PATCH 14/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=20(departmentId,=20course)=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF.=20=D0=94=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20Gro?= =?UTF-8?q?upController.java?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/GroupController.java | 118 +++++++++++++----- .../magistr/app/dto/CreateGroupRequest.java | 18 +++ .../com/magistr/app/dto/GroupResponse.java | 14 ++- .../com/magistr/app/model/StudentGroup.java | 22 ++++ 4 files changed, 139 insertions(+), 33 deletions(-) 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..183ed51 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -6,6 +6,9 @@ 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -17,6 +20,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 +33,105 @@ public class GroupController { @GetMapping public List getAllGroups() { - return groupRepository.findAll().stream() - .map(g -> new GroupResponse( + logger.info("Получен запрос на получение всех групп"); + + try { + List groups = groupRepository.findAll(); + + List response = groups.stream() + .map(g -> new GroupResponse( g.getId(), g.getName(), g.getGroupSize(), g.getEducationForm().getId(), - g.getEducationForm().getName())) - .toList(); + g.getEducationForm().getName(), + g.getDepartmentId(), + g.getCourse() + )) + .toList(); + logger.info("Получено {} групп", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e); + throw e; + } } @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 = {}, course = {}", + request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getCourse()); + 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)); + } - 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.setCourse(request.getCourse()); + 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.getCourse())); + } 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/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index dfbe5a0..5007602 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,8 @@ public class CreateGroupRequest { private String name; private Long groupSize; private Long educationFormId; + private Long departmentId; + private Integer course; public String getName() { return name; @@ -29,4 +31,20 @@ 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 getCourse() { + return course; + } + + public void setCourse(Integer course) { + this.course = course; + } } 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..9d0216b 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -7,13 +7,17 @@ public class GroupResponse { private Long groupSize; private Long educationFormId; private String educationFormName; + private Long departmentId; + private Integer course; - 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) { this.id = id; this.name = name; this.groupSize = groupSize; this.educationFormId = educationFormId; this.educationFormName = educationFormName; + this.departmentId = departmentId; + this.course = course; } public Long getId() { @@ -35,4 +39,12 @@ public class GroupResponse { public String getEducationFormName() { return educationFormName; } + + public Long getDepartmentId() { + return departmentId; + } + + public Integer getCourse() { + return course; + } } 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..ed1ca2b 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,12 @@ public class StudentGroup { @JoinColumn(name = "education_form_id", nullable = false) private EducationForm educationForm; + @Column(name = "department_id", nullable = false) + private Long departmentId; + + @Column(name = "course", nullable = false) + private Integer course; + public StudentGroup() { } @@ -54,4 +60,20 @@ 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 Integer getCourse() { + return course; + } + + public void setCourse(Integer course) { + this.course = course; + } } From 49ca2e17b6aeeab68298e679829505a4ce474ca2 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Sat, 21 Mar 2026 11:41:01 +0300 Subject: [PATCH 15/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=20(departmentId,=20code)=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B4=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20SubjectController.java?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/SubjectController.java | 86 ++++++++++++++++--- .../app/controller/UserController.java | 2 +- .../magistr/app/dto/CreateSubjectRequest.java | 50 +++++++++++ .../com/magistr/app/dto/SubjectResponse.java | 35 ++++++++ .../java/com/magistr/app/model/Subject.java | 26 +++++- 5 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateSubjectRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SubjectResponse.java 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..ff801af 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,84 @@ 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; + } } @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 0f97426..2212c3a 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -31,7 +31,7 @@ public class UserController { @GetMapping public List getAllUsers() { - logger.info("Запрос на получение всех пользователей"); + logger.info("Получен запрос на получение всех пользователей"); try { List users = userRepository.findAll(); 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/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/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; + } } From 0817961d972294b50af59952a2e1b83887d21224 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Sat, 21 Mar 2026 13:00:30 +0300 Subject: [PATCH 16/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D0=B4?= =?UTF-8?q?=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8=D0=BD=20=D0=B8=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D0=B5?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/GroupController.java | 22 ++++++++++++ .../app/controller/SubjectController.java | 21 ++++++++++++ .../app/controller/UserController.java | 34 +++++++++++++++++++ .../com/magistr/app/dto/UserResponse.java | 11 ++++++ .../app/repository/GroupRepository.java | 2 ++ .../app/repository/SubjectRepository.java | 3 ++ .../app/repository/UserRepository.java | 2 ++ 7 files changed, 95 insertions(+) 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 183ed51..fbb7590 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -57,6 +57,28 @@ public class GroupController { } } + @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("Группы для указанной кафедры не найдены"); + } + + logger.info("Найдено {} групп для кафедры с ID - {}", groups.size(), departmentId); + + return ResponseEntity.ok(groups); + } catch (Exception e) { + logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Произошла ошибка при получении списка групп"); + } + } + @PostMapping public ResponseEntity createGroup(@RequestBody CreateGroupRequest request) { logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, course = {}", 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 ff801af..078c891 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -47,6 +47,27 @@ public class SubjectController { } } + @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 CreateSubjectRequest request) { logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}", 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 2212c3a..20d8f3c 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -79,6 +79,40 @@ public class UserController { } } + @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) { logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId()); 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 d4d30de..731cf8a 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -1,5 +1,8 @@ package com.magistr.app.dto; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public class UserResponse { private Long id; @@ -21,6 +24,14 @@ public class UserResponse { 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; } 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/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); } From 798d61c7eac839b9b9f1b290eb66e07e9b4da94e Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Wed, 25 Mar 2026 22:41:53 +0300 Subject: [PATCH 17/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C,=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D1=84=D0=B5=D0=B4=D1=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/DepartmentController.java | 106 ++++++++++++++++++ .../app/controller/SpecialityController.java | 106 ++++++++++++++++++ .../app/dto/CreateDepartmentRequest.java | 25 +++++ .../app/dto/CreateSpecialityRequest.java | 25 +++++ .../magistr/app/dto/DepartmentResponse.java | 38 +++++++ .../magistr/app/dto/SpecialityResponse.java | 38 +++++++ .../com/magistr/app/model/Department.java | 50 +++++++++ .../com/magistr/app/model/Speciality.java | 50 +++++++++ .../app/repository/DepartmentRepository.java | 13 +++ .../app/repository/SpecialtiesRepository.java | 13 +++ 10 files changed, 464 insertions(+) create mode 100644 backend/src/main/java/com/magistr/app/controller/DepartmentController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/SpecialityController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateDepartmentRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateSpecialityRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/DepartmentResponse.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SpecialityResponse.java create mode 100644 backend/src/main/java/com/magistr/app/model/Department.java create mode 100644 backend/src/main/java/com/magistr/app/model/Speciality.java create mode 100644 backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java 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/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/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/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/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/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/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/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/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/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); +} From 4915e6f33b58e1a1f92468f54d63aa17ae9ffe0a Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Wed, 25 Mar 2026 23:49:10 +0300 Subject: [PATCH 18/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ScheduleDataController.java | 54 +++++++ .../app/dto/CreateScheduleDataRequest.java | 103 ++++++++++++ .../com/magistr/app/dto/ScheduleResponse.java | 120 ++++++++++++++ .../com/magistr/app/model/ScheduleData.java | 146 ++++++++++++++++++ .../repository/ScheduleDataRepository.java | 7 + 5 files changed, 430 insertions(+) create mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleData.java create mode 100644 backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java 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..d289f86 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java @@ -0,0 +1,54 @@ +package com.magistr.app.controller; + +import com.magistr.app.model.Department; +import com.magistr.app.model.ScheduleData; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.ScheduleDataRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/scheduledata") +public class ScheduleDataController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class); + + private final ScheduleDataRepository scheduleDataRepository; + + public ScheduleDataController(ScheduleDataRepository scheduleDataRepository) { + this.scheduleDataRepository = scheduleDataRepository; + } + + @GetMapping + public List getAllScheduleDataList() { + logger.info("Получен запрос на получение списка данных расписаний"); + try { + List scheduleData = scheduleDataRepository.findAll(); + List response = scheduleData.stream() + .map(s -> new ScheduleData( + s.getId(), + s.getDepartmentId(), + s.getSemester(), + s.getGroupId(), + s.getSubjectsId(), + s.getLessonTypeId(), + s.getNumberOfHours(), + s.getDivision(), + s.getTeacherId(), + s.getSemesterType(), + s.getPeriod() + )) + .toList(); + logger.info("Получено {} записей", response.size()); + return response; + } catch (Exception e) { + logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e); + throw e; + } + } +} \ No newline at end of file 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..858436b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java @@ -0,0 +1,103 @@ +package com.magistr.app.dto; + +public class CreateScheduleDataRequest { + private Long id; + private Long departmentId; + private Long semester; + private Long groupId; + private Long subjectsId; + private Long lessonTypeId; + private Long numberOfHours; + private Boolean isDivision; + private Long teacherId; + private String semesterType; + private String period; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getSemester() { + return semester; + } + + public void setSemester(Long semester) { + this.semester = semester; + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public void setSubjectsId(Long subjectsId) { + this.subjectsId = subjectsId; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public void setLessonTypeId(Long lessonTypeId) { + this.lessonTypeId = lessonTypeId; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public void setNumberOfHours(Long numberOfHours) { + this.numberOfHours = numberOfHours; + } + + public Boolean getDivision() { + return isDivision; + } + + public void setDivision(Boolean division) { + isDivision = division; + } + + public Long getTeacherId() { + return teacherId; + } + + public void setTeacherId(Long teacherId) { + this.teacherId = teacherId; + } + + public String getSemesterType() { + return semesterType; + } + + public void setSemesterType(String 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/ScheduleResponse.java b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java new file mode 100644 index 0000000..7d906c3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java @@ -0,0 +1,120 @@ +package com.magistr.app.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ScheduleResponse { + private Long id; + private Long departmentId; + private Long semester; + private Long groupId; + private Long subjectsId; + private Long lessonTypeId; + private Long numberOfHours; + private Boolean isDivision; + private Long teacherId; + private String semesterType; + private String period; + + public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.semester = semester; + this.groupId = groupId; + this.subjectsId = subjectsId; + this.lessonTypeId = lessonTypeId; + this.numberOfHours = numberOfHours; + this.isDivision = isDivision; + this.teacherId = teacherId; + this.semesterType = semesterType; + this.period = period; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getSemester() { + return semester; + } + + public void setSemester(Long semester) { + this.semester = semester; + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public void setSubjectsId(Long subjectsId) { + this.subjectsId = subjectsId; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public void setLessonTypeId(Long lessonTypeId) { + this.lessonTypeId = lessonTypeId; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public void setNumberOfHours(Long numberOfHours) { + this.numberOfHours = numberOfHours; + } + + public Boolean getDivision() { + return isDivision; + } + + public void setDivision(Boolean division) { + isDivision = division; + } + + public Long getTeacherId() { + return teacherId; + } + + public void setTeacherId(Long teacherId) { + this.teacherId = teacherId; + } + + public String getSemesterType() { + return semesterType; + } + + public void setSemesterType(String 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/ScheduleData.java b/backend/src/main/java/com/magistr/app/model/ScheduleData.java new file mode 100644 index 0000000..ecc7909 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleData.java @@ -0,0 +1,146 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name="schedule_data") +public class ScheduleData { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name="department_id", nullable = false) + private Long departmentId; + + @Column(name="semester", nullable = false) + private Long semester; + + @Column(name="group_id", nullable = false) + private Long groupId; + + @Column(name="subjects_id", nullable = false) + private Long subjectsId; + + @Column(name="lesson_type_id", nullable = false) + private Long lessonTypeId; + + @Column(name="number_of_hours", nullable = false) + private Long numberOfHours; + + @Column(name="is_division", nullable = false) + private Boolean isDivision; + + @Column(name="teacher_id", nullable = false) + private Long teacherId; + + @Column(name="semester_type", nullable = false) + private String semesterType; + + @Column(name="period", nullable = false) + private String period; + + public ScheduleData() {} + + public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.semester = semester; + this.groupId = groupId; + this.subjectsId = subjectsId; + this.lessonTypeId = lessonTypeId; + this.numberOfHours = numberOfHours; + this.isDivision = isDivision; + this.teacherId = teacherId; + this.semesterType = semesterType; + this.period = period; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getSemester() { + return semester; + } + + public void setSemester(Long semester) { + this.semester = semester; + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public Long getSubjectsId() { + return subjectsId; + } + + public void setSubjectsId(Long subjectsId) { + this.subjectsId = subjectsId; + } + + public Long getLessonTypeId() { + return lessonTypeId; + } + + public void setLessonTypeId(Long lessonTypeId) { + this.lessonTypeId = lessonTypeId; + } + + public Long getNumberOfHours() { + return numberOfHours; + } + + public void setNumberOfHours(Long numberOfHours) { + this.numberOfHours = numberOfHours; + } + + public Boolean getDivision() { + return isDivision; + } + + public void setDivision(Boolean division) { + isDivision = division; + } + + public Long getTeacherId() { + return teacherId; + } + + public void setTeacherId(Long teacherId) { + this.teacherId = teacherId; + } + + public String getSemesterType() { + return semesterType; + } + + public void setSemesterType(String 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/repository/ScheduleDataRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java new file mode 100644 index 0000000..82443f6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java @@ -0,0 +1,7 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleData; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleDataRepository extends JpaRepository { +} From 9e7b35aa0bb8aad58d32a0d08302ad24a3635042 Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 19 Mar 2026 23:47:01 +0300 Subject: [PATCH 19/45] feat: Add OpenTelemetry integration by creating `otel.js` and importing it into `main.js`. --- frontend/admin/js/main.js | 2 ++ frontend/admin/js/otel.js | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 frontend/admin/js/otel.js diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 6745859..ae85f6a 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -1,3 +1,5 @@ +import './otel.js'; + import { isAuthenticatedAsAdmin } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; diff --git a/frontend/admin/js/otel.js b/frontend/admin/js/otel.js new file mode 100644 index 0000000..2de329d --- /dev/null +++ b/frontend/admin/js/otel.js @@ -0,0 +1,47 @@ +import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0'; +import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0'; +import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1'; +import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0'; +import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1'; +import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0'; +import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0'; +import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0'; + +// Инициализация провайдера метрик и трейсов с именем сервиса +const provider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin', + }), +}); + +// Экспортер отправляет данные на относительный путь /otel/v1/traces. +// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318). +const traceExporter = new OTLPTraceExporter({ + url: window.location.origin + '/otel/v1/traces', +}); + +// Использование BatchSpanProcessor для буферизации трейсов перед отправкой +provider.addSpanProcessor(new BatchSpanProcessor(traceExporter)); + +// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc) +provider.register({ + contextManager: new ZoneContextManager(), +}); + +// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc) +registerInstrumentations({ + instrumentations: [ + getWebAutoInstrumentations({ + '@opentelemetry/instrumentation-fetch': { + propagateTraceHeaderCorsUrls: /.*/, + clearTimingResources: true, + }, + '@opentelemetry/instrumentation-xml-http-request': { + propagateTraceHeaderCorsUrls: /.*/, + clearTimingResources: true, + }, + }), + ], +}); + +console.log('OpenTelemetry Web SDK initialized successfully.'); From ec7e615557833ee2b8b15a303c62c7c86b57d678 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 22 Mar 2026 02:49:13 +0300 Subject: [PATCH 20/45] docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md. --- .gitignore | 4 +- AGENTS.md | 169 +++-------------- docs/API.md | 420 +++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 142 ++++++++++++++ docs/BUSINESS_LOGIC.md | 149 +++++++++++++++ docs/DATABASE.md | 362 +++++++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 275 +++++++++++++++++++++++++++ docs/FRONTEND.md | 203 ++++++++++++++++++++ docs/INFRASTRUCTURE.md | 137 ++++++++++++++ docs/README.md | 113 +++++++++++ 10 files changed, 1829 insertions(+), 145 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUSINESS_LOGIC.md create mode 100644 docs/DATABASE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/FRONTEND.md create mode 100644 docs/INFRASTRUCTURE.md create mode 100644 docs/README.md diff --git a/.gitignore b/.gitignore index b362f1c..98c3a42 100755 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ backend/build/ frontend/node_modules/ frontend/dist/ -.agents .idea/ .vscode/ -*.DS_Store -GEMINI.md \ No newline at end of file +*.DS_Store \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1e900b4..033f2d5 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,174 +28,59 @@ magistr/ │ ├── admin/ # Интерфейс администратора │ ├── 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, адаптивность | diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f8e40c3 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,420 @@ +# 🔌 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" }, + { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER" } +] +``` + +### `GET /api/users/teachers` + +Список только преподавателей (роль `TEACHER`). + +### `POST /api/users` + +Создание пользователя. + +**Тело запроса:** +```json +{ + "username": "Новый преподаватель", + "password": "password123", + "role": "TEACHER" +} +``` + +**Валидация:** +- `username` — обязателен +- `password` — минимум 4 символа +- `role` — `ADMIN`, `TEACHER` или `STUDENT` + +### `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 — передаются только изменённые поля. + +### `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": "Бакалавриат" + } +] +``` + +### `POST /api/groups` + +Создание группы. + +```json +{ + "name": "ИБ-31м", + "groupSize": 20, + "educationFormId": 2 +} +``` + +### `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` + +Список всех дисциплин. + +### `POST /api/subjects` + +```json +{ "name": "Физика" } +``` + +### `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..c571a55 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,203 @@ +# 🎨 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 # Модальные окна +│ ├── 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 # Управление тенантами +│ └── views/ # HTML-шаблоны представлений +│ ├── users.html +│ ├── groups.html +│ ├── classrooms.html +│ ├── subjects.html +│ ├── equipments.html +│ ├── edu-forms.html +│ ├── schedule.html +│ └── database.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` | + +--- + +## 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'; +} +``` + +### Выход + +Кнопка «Выйти» очищает `localStorage` и перенаправляет на `/`. + +--- + +## CSS-архитектура + +### Модульный подход + +Стили разделены на 4 файла (порядок подключения важен): + +1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема +2. **`layout.css`** — Sidebar, topbar, content area, responsive +3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы +4. **`modals.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 скрывается на экранах < 768px +- Появляется кнопка-гамбургер (`#menu-toggle`) +- Sidebar выезжает как overlay +- Таблицы получают горизонтальный скролл 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/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) | Архитектура фронтенда, модули, стили | From 599e284ea9b278f992a6e5fcb24b20f8468d9365 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 22 Mar 2026 15:22:10 +0300 Subject: [PATCH 21/45] feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md. --- .agents/skills/AutoUpdateDocs.md | 85 ++++++++++++++++ AGENTS.md | 1 + docs/LOGGING.md | 167 +++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 .agents/skills/AutoUpdateDocs.md create mode 100644 docs/LOGGING.md diff --git a/.agents/skills/AutoUpdateDocs.md b/.agents/skills/AutoUpdateDocs.md new file mode 100644 index 0000000..8910bf4 --- /dev/null +++ b/.agents/skills/AutoUpdateDocs.md @@ -0,0 +1,85 @@ +--- +name: AutoUpdateDocs +description: Автоматическое обновление документации проекта после изменений в коде +--- + +# Скилл: Автоматическое обновление документации + +## Когда активировать + +Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих: + +- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md` +- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md` +- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md` +- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md` +- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md` +- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md` +- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md` +- **Общая структура проекта** → обновить `docs/README.md` + +## Карта соответствия «файл → документация» + +| Изменённый файл/директория | Файл документации | +|----------------------------|-------------------| +| `controller/*Controller.java` | `docs/API.md` | +| `db/migration/V*__.sql` | `docs/DATABASE.md` | +| `model/*.java` | `docs/DATABASE.md` | +| `dto/*.java` | `docs/API.md` | +| `config/tenant/*.java` | `docs/ARCHITECTURE.md` | +| `utils/*.java` | `docs/BUSINESS_LOGIC.md` | +| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` | +| `frontend/admin/css/*.css` | `docs/FRONTEND.md` | +| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` | +| `application.properties` | `docs/ARCHITECTURE.md` | + +## Пошаговая инструкция + +### 1. Определить затронутые файлы документации + +После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить. + +### 2. Прочитать текущую документацию + +Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить. + +### 3. Внести точечные изменения + +Обновить **только затронутые секции**, не переписывая весь файл. Примеры: + +#### Новый контроллер → `docs/API.md` +Добавить новую секцию с описанием эндпоинтов: +- Метод + URL +- Тело запроса (JSON пример) +- Ответ (JSON пример) +- Валидация + +#### Новая миграция → `docs/DATABASE.md` +- Добавить новую таблицу в ER-диаграмму (Mermaid) +- Добавить описание таблицы и колонок +- Добавить запись в таблицу «Текущие миграции» + +#### Новый view → `docs/FRONTEND.md` +- Добавить в дерево файлов +- Добавить в таблицу «Разделы админ-панели» + +### 4. Обновить AGENTS.md (при необходимости) + +Если изменения затрагивают: +- Структуру директорий → обновить дерево в `AGENTS.md` +- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила» + +### 5. Сообщить пользователю + +В конце ответа кратко упомянуть, какие файлы документации были обновлены: + +> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`) + +## Правила + +1. **Язык:** Вся документация на русском языке +2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода) +3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя +4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md` +5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения +6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения diff --git a/AGENTS.md b/AGENTS.md index 033f2d5..a6da314 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,3 +84,4 @@ docker compose logs -f backend | [`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 | 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` From 3861fa05b5846644d9b2c9246e1005313ce59dee Mon Sep 17 00:00:00 2001 From: Zuev Date: Mon, 23 Mar 2026 02:05:02 +0300 Subject: [PATCH 22/45] feat: add frontend-design skill with its documentation and license, and update gitignore. --- .agents/skills/frontend-design/LICENSE.txt | 177 +++++++++++++++++++++ .agents/skills/frontend-design/SKILL.md | 42 +++++ .gitignore | 3 +- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/frontend-design/LICENSE.txt create mode 100644 .agents/skills/frontend-design/SKILL.md 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 98c3a42..9647f9d 100755 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ frontend/dist/ .idea/ .vscode/ -*.DS_Store \ No newline at end of file +*.DS_Store +skills-lock.json \ No newline at end of file From 7ce0d1e501de3e9b46f9ec64babf00ec58429003 Mon Sep 17 00:00:00 2001 From: alekan Date: Wed, 25 Mar 2026 23:18:05 +0300 Subject: [PATCH 23/45] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D1=8B/=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/1.md | 6 ++ .agents/skills/SKILL.md | 29 ++++++ frontend/admin/css/departments-data.css | 11 +++ frontend/admin/index.html | 8 ++ frontend/admin/js/main.js | 2 + frontend/admin/js/views/departments-data.js | 103 ++++++++++++++++++++ frontend/admin/views/departments-data.html | 80 +++++++++++++++ 7 files changed, 239 insertions(+) create mode 100644 .agents/rules/1.md create mode 100644 .agents/skills/SKILL.md create mode 100644 frontend/admin/css/departments-data.css create mode 100644 frontend/admin/js/views/departments-data.js create mode 100644 frontend/admin/views/departments-data.html diff --git a/.agents/rules/1.md b/.agents/rules/1.md new file mode 100644 index 0000000..513a1d3 --- /dev/null +++ b/.agents/rules/1.md @@ -0,0 +1,6 @@ +--- +trigger: always_on +glob: +description: +--- + diff --git a/.agents/skills/SKILL.md b/.agents/skills/SKILL.md new file mode 100644 index 0000000..ec02af3 --- /dev/null +++ b/.agents/skills/SKILL.md @@ -0,0 +1,29 @@ +Контекст проекта: + +Backend: Java 17, Spring Framework. Учитывай возможности этой версии языка и стандарты фреймворка. + +Frontend: HTML, CSS, JavaScript. + +Правила написания кода и комментариев: + +Проверка: Перед написанием кода изучи проект, его структуру и используемые технологии. Не предлагай решения, которые не соответствуют текущей архитектуре или стеку. + +Язык: Все комментарии и объяснения должны быть строго на русском языке. + +Комментирование кода: Оставляй комментарии, объясняющие, за что отвечает та или иная часть кода. Перед крупными или смысловыми блоками обязательно ставь поясняющие метки (например: ``, /* таблица subjects */, // логика обработки subjects). + +Обоснование решений: При написании нового кода кратко и максимально понятно объясняй, почему мы используем именно это решение, а не другое. + +Современные подходы: На фронтенде используй самые современные и актуальные подходы (например, Flexbox, CSS Grid, семантические теги). + +Правила работы с ошибками (Обучающий режим): + +Если ты находишь ошибку в моем коде, не пиши сразу готовый исправленный код. + +Дай мне точную подсказку, чтобы я мог сам найти и исправить баг (например: "У тебя не закрыт тег в 15 строке", "Ты забыл поставить аннотацию в контроллере Spring" или "Проверь отступы в таком-то классе"). Моя цель — научиться. + +Правила работы с дизайном (UI/UX): + +Перед добавлением новых стилей всегда сначала изучай, какие стили уже используются в проекте, чтобы сохранять единообразие. + +Если ты видишь, что текущий дизайн откровенно плох, нелогичен или устарел — смело предлагай свои идеи по улучшению (цветовая палитра, отступы, шрифты). Я открыт к предложениям по улучшению визуала. \ No newline at end of file diff --git a/frontend/admin/css/departments-data.css b/frontend/admin/css/departments-data.css new file mode 100644 index 0000000..7832560 --- /dev/null +++ b/frontend/admin/css/departments-data.css @@ -0,0 +1,11 @@ +/* Стили для формы создания кафедр и специальностей */ +.departments-data-icon { + margin-right: 0.5rem; +} + +#departments-tbody .loading-row, +#specialties-tbody .loading-row { + text-align: center; + color: var(--text-muted); + padding: 2rem; +} diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 7ddec41..158ee8d 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -15,6 +15,7 @@ + @@ -58,6 +59,13 @@ Кафедра + + + + + + Создание кафедры/специальности + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index ae85f6a..dbbd55a 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -12,6 +12,7 @@ import { initSubjects } from './views/subjects.js'; import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; import {initDepartment} from "./views/department.js"; +import {initDepartmentsData} from "./views/departments-data.js"; // Configuration const ROUTES = { @@ -24,6 +25,7 @@ const ROUTES = { schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment }, + 'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData }, }; let currentTab = null; 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/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НазваниеКод специальности
Загрузка...
+
+
From e71bcee9b5faf0d193da21ea0282b9317a8ee992 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 25 Mar 2026 23:53:23 +0300 Subject: [PATCH 24/45] chore: Configure database healthcheck, backend service dependency on DB health, and disable OpenTelemetry SDK. --- compose.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From f7483e7aeb95eff470d038bd2342e10d46198779 Mon Sep 17 00:00:00 2001 From: alekan Date: Thu, 26 Mar 2026 00:37:31 +0300 Subject: [PATCH 25/45] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=83=20"?= =?UTF-8?q?=D0=9A=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D0=B0",=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/admin/css/department.css | 471 +++++++++++++------------- frontend/admin/js/views/department.js | 121 ++++++- frontend/admin/views/department.html | 231 +++---------- 3 files changed, 397 insertions(+), 426 deletions(-) diff --git a/frontend/admin/css/department.css b/frontend/admin/css/department.css index 691860e..d39a250 100644 --- a/frontend/admin/css/department.css +++ b/frontend/admin/css/department.css @@ -1,235 +1,236 @@ -.wrap{ - max-width: 900px; - margin: 0 auto; - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - box-shadow: 0 6px 20px rgba(0,0,0,.06); -} - -.header{ - padding: 14px 16px; - border-bottom: 1px solid var(--bg-card-border); - font-weight: 700; - color: var(--text-primary); -} - -details.table-item{ - border-top: 1px solid var(--bg-card-border); -} -details.table-item:first-of-type{ border-top:none; } - -summary{ - list-style: none; - cursor: pointer; - user-select: none; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 10px; -} -summary::-webkit-details-marker{ display:none; } - -.chev{ - width: 28px; - height: 28px; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - display: grid; - place-items: center; - flex: 0 0 auto; - - color: var(--text-secondary); - background: var(--bg-input); - - transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; -} - -.chev-icon{ - width: 16px; - height: 16px; - display: block; -} - -summary:hover .chev{ - background: var(--bg-hover); - border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); - color: var(--text-primary); -} - -details[open] .chev{ - transform: rotate(180deg); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); -} - -.meta{ color: var(--text-secondary); font-size: 12px; } - -.content{ padding: 0 16px 16px 16px; } - -.wrap table{ - width: 100%; - border-collapse: collapse; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - overflow: hidden; - background: var(--bg-card); -} - -.wrap thead th{ - text-align: left; - font-size: 13px; - color: var(--text-secondary); - background: var(--bg-input); - border-bottom: 1px solid var(--bg-card-border); - padding: 10px 12px; -} - -.wrap tbody td{ - padding: 10px 12px; - border-bottom: 1px solid var(--bg-card-border); - font-size: 14px; - color: var(--text-primary); -} - -.wrap tbody tr:hover{ background: var(--bg-hover); } - -.title-multiline{ - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.2; -} - -.title-multiline .title-main{ - font-weight: 700; - color: var(--text-primary); -} - -.title-multiline .title-sub{ - font-weight: 500; - font-size: 12px; - color: var(--text-secondary); -} - -.title-multiline b{ - font-weight: 700; - color: var(--text-primary); -} - -/* summary = 3 колонки: [chev] [title] [meta] */ -details.table-item > summary{ - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 12px; - align-items: start; /* важно: всё прижимаем к верху */ - padding: 12px 16px; -} - -/* чтобы текст нормально переносился и не растягивал мету */ -details.table-item > summary .title{ - min-width: 0; /* важно для grid, иначе может распирать */ -} - -/* "2 записи" всегда справа и сверху, аккуратно */ -details.table-item > summary .meta{ - justify-self: end; - align-self: start; - white-space: nowrap; - padding-top: 4px; /* чуть опустить относительно первой строки */ - font-size: 12px; - color: var(--text-secondary); -} - -/* стрелка тоже сверху */ -details.table-item > summary .chev{ - align-self: start; - margin-top: 2px; -} - -.records-search{ - width: min(360px, 60vw); - padding: 0.45rem 0.7rem; - background: var(--bg-input); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 0.9rem; - outline: none; - transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; -} - -.records-search::placeholder{ color: var(--text-placeholder); } - -.records-search:focus{ - background: var(--bg-input-focus); - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} -/* Таблица внутри раскрывающегося блока */ -details.table-item .content table{ - width: 100%; - border-collapse: separate; /* нужно для красивых линий */ - border-spacing: 0; - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - background: var(--bg-card); -} - -/* Шапка */ -details.table-item .content thead th{ - position: sticky; /* опционально: шапка прилипает при скролле */ - top: 0; - z-index: 1; - - background: var(--bg-input); - color: var(--text-secondary); - border-bottom: 1px solid var(--bg-card-border); -} - -/* Ячейки: одинаковые отступы */ -details.table-item .content th, -details.table-item .content td{ - padding: 0.75rem 0.85rem; - vertical-align: top; -} - -/* Вертикальные разделители между колонками */ -details.table-item .content th:not(:last-child), -details.table-item .content td:not(:last-child){ - border-right: 1px solid var(--bg-card-border); -} - -/* Горизонтальные разделители между строками */ -details.table-item .content tbody td{ - border-bottom: 1px solid var(--bg-card-border); - color: var(--text-primary); -} - -/* У последней строки нет нижней линии */ -details.table-item .content tbody tr:last-child td{ - border-bottom: none; -} - -/* "Зебра" для читабельности */ -details.table-item .content tbody tr:nth-child(even){ - background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); -} - -/* Ховер по строке */ -details.table-item .content tbody tr:hover{ - background: var(--bg-hover); -} - -/* (Опционально) Чтобы длинный текст не ломал ширину */ -details.table-item .content td{ - word-break: break-word; -} - -/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ -details.table-item .content{ - overflow-x: auto; -} \ No newline at end of file +.wrap{ + max-width: 900px; + margin: 0 auto; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 6px 20px rgba(0,0,0,.06); +} + +.header{ + padding: 14px 16px; + border-bottom: 1px solid var(--bg-card-border); + font-weight: 700; + color: var(--text-primary); +} + +details.table-item{ + border-top: 1px solid var(--bg-card-border); +} +details.table-item:first-of-type{ border-top:none; } + +summary{ + list-style: none; + cursor: pointer; + user-select: none; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 10px; +} +summary::-webkit-details-marker{ display:none; } + +.chev{ + width: 28px; + height: 28px; + border: 1px solid var(--bg-card-border); + border-radius: 10px; + display: grid; + place-items: center; + flex: 0 0 auto; + + color: var(--text-secondary); + background: var(--bg-input); + + transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; +} + +.chev-icon{ + width: 16px; + height: 16px; + display: block; +} + +summary:hover .chev{ + background: var(--bg-hover); + border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); + color: var(--text-primary); +} + +details[open] .chev{ + transform: rotate(180deg); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); +} + +.meta{ color: var(--text-secondary); font-size: 12px; } + +.content{ padding: 0 16px 16px 16px; } + +.wrap table{ + width: 100%; + border-collapse: collapse; + border: 1px solid var(--bg-card-border); + border-radius: 10px; + overflow: hidden; + background: var(--bg-card); +} + +.wrap thead th{ + text-align: left; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-input); + border-bottom: 1px solid var(--bg-card-border); + padding: 10px 12px; +} + +.wrap tbody td{ + padding: 10px 12px; + border-bottom: 1px solid var(--bg-card-border); + font-size: 14px; + color: var(--text-primary); +} + +.wrap tbody tr:hover{ background: var(--bg-hover); } + +.title-multiline{ + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.2; +} + +.title-multiline .title-main{ + font-weight: 700; + color: var(--text-primary); +} + +.title-multiline .title-sub{ + font-weight: 500; + font-size: 12px; + color: var(--text-secondary); +} + +.title-multiline b{ + font-weight: 700; + color: var(--text-primary); +} + +/* summary = 3 колонки: [chev] [title] [meta] */ +details.table-item > summary{ + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 12px; + align-items: start; /* важно: всё прижимаем к верху */ + padding: 12px 16px; +} + +/* чтобы текст нормально переносился и не растягивал мету */ +details.table-item > summary .title{ + min-width: 0; /* важно для grid, иначе может распирать */ +} + +/* "2 записи" всегда справа и сверху, аккуратно */ +details.table-item > summary .meta{ + justify-self: end; + align-self: start; + white-space: nowrap; + padding-top: 4px; /* чуть опустить относительно первой строки */ + font-size: 12px; + color: var(--text-secondary); +} + +/* стрелка тоже сверху */ +details.table-item > summary .chev{ + align-self: start; + margin-top: 2px; +} + +.records-search{ + width: min(360px, 60vw); + padding: 0.45rem 0.7rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; + outline: none; + transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; +} + +.records-search::placeholder{ color: var(--text-placeholder); } + +.records-search:focus{ + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} +/* Таблица внутри раскрывающегося блока */ +details.table-item .content table{ + width: 100%; + border-collapse: separate; /* нужно для красивых линий */ + border-spacing: 0; + border: 1px solid var(--bg-card-border); + border-radius: 12px; + overflow: hidden; + background: var(--bg-card); +} + +/* Шапка */ +details.table-item .content thead th{ + position: sticky; /* опционально: шапка прилипает при скролле */ + top: 0; + z-index: 1; + + background: var(--bg-input); + color: var(--text-secondary); + border-bottom: 1px solid var(--bg-card-border); +} + +/* Ячейки: одинаковые отступы */ +details.table-item .content th, +details.table-item .content td{ + padding: 0.75rem 0.85rem; + vertical-align: top; +} + +/* Вертикальные разделители между колонками */ +details.table-item .content th:not(:last-child), +details.table-item .content td:not(:last-child){ + border-right: 1px solid var(--bg-card-border); +} + +/* Горизонтальные разделители между строками */ +details.table-item .content tbody td{ + border-bottom: 1px solid var(--bg-card-border); + color: var(--text-primary); +} + +/* У последней строки нет нижней линии */ +details.table-item .content tbody tr:last-child td{ + border-bottom: none; +} + +/* "Зебра" для читабельности */ +details.table-item .content tbody tr:nth-child(even){ + background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); +} + +/* Ховер по строке */ +details.table-item .content tbody tr:hover{ + background: var(--bg-hover); +} + +/* (Опционально) Чтобы длинный текст не ломал ширину */ +details.table-item .content td{ + word-break: break-word; +} + +/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ +details.table-item .content{ + overflow-x: auto; +} + \ No newline at end of file diff --git a/frontend/admin/js/views/department.js b/frontend/admin/js/views/department.js index b7c3711..9b0a1c0 100644 --- a/frontend/admin/js/views/department.js +++ b/frontend/admin/js/views/department.js @@ -1,4 +1,121 @@ import { api } from '../api.js'; -import { escapeHtml } from '../utils.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; -export async function initDepartment() { } \ No newline at end of file +export async function initDepartment() { + const form = document.getElementById('department-schedule-form'); + const departmentSelect = document.getElementById('filter-department'); + const container = document.getElementById('schedule-blocks-container'); + + let departments = []; + + // Загрузка кафедр + try { + departments = await api.get('/api/departments'); + departmentSelect.innerHTML = '' + + departments.map(d => ``).join(''); + } catch (e) { + departmentSelect.innerHTML = ''; + } + + 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('-', '/'); // Display 2024-2025 as 2024/2025 + + renderScheduleBlock(deptName, semesterName, periodName, data); + + form.reset(); + + } catch (err) { + showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error'); + } + }); + + function renderScheduleBlock(deptName, semester, period, schedule) { + const details = document.createElement('details'); + details.className = 'table-item'; + details.open = true; // Сразу открываем новый блок + + details.innerHTML = ` + + +
+ Данные к составлению расписания + Кафедра: ${escapeHtml(deptName)} + Семестр: ${escapeHtml(semester)} + Уч. год: ${escapeHtml(period)} +
+
${schedule ? schedule.length : 0} записей
+
+ +
+ + + + + + + + + + + + + + + ${renderRows(schedule)} + +
СпециальностьКурс/семестрГруппаДисциплинаВид занятийЧасов в неделюАудиторияФамилия преподавателя
+
+ `; + + container.prepend(details); + } + + function renderRows(schedule) { + if (!schedule || schedule.length === 0) { + return 'Нет данных'; + } + + return schedule.map(r => ` + + ${escapeHtml(r.specialty || '-')} + ${escapeHtml(r.courseSemester || '-')} + ${escapeHtml(r.groupName || '-')} + ${escapeHtml(r.subjectName || '-')} + ${escapeHtml(r.lessonType || '-')} + ${escapeHtml(r.hours || '-')} + ${escapeHtml(r.classroom || '-')} + ${escapeHtml(r.teacherName || '-')} + + `).join(''); + } +} \ No newline at end of file diff --git a/frontend/admin/views/department.html b/frontend/admin/views/department.html index 74dc1bf..c86cb66 100644 --- a/frontend/admin/views/department.html +++ b/frontend/admin/views/department.html @@ -1,193 +1,46 @@ -
-

Кафедра

+
+

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

+
+
+
+ + +
-
- - - -
+
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
- -
- - -
- - -
- Данные к составлению расписания - Кафедра: Информационная безопасность - Факультет: ФиПИ - Семестр: весенний - Уч. год: 2024/2025 -
-
3 записи
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
09.02.072 курс, 4 семестрИС-21Базы данныхЛабораторная2ДаИванов
09.02.072 курс, 4 семестрИС-22Операционные системыПрактика1НетСмирнов
09.02.071 курс, 2 семестрИС-12АлгоритмыЛекция2НетКузнецов
-
-
- - -
- - -
orders
-
1 запись
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
38.02.011 курс, 1 семестрЭК-11ЭкономикаЛекция1НетПетров
-
-
- - -
- - -
products
-
2 записи
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
СпециальностьКурс и семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыФамилия преподавателя
15.02.083 курс, 6 семестрМС-31МатериаловедениеПрактика3ДаСидоров
15.02.083 курс, 6 семестрМС-32Технология металловЛабораторная2ДаОрлов
-
-
- -
+
+
\ No newline at end of file From 7a2c3852579db777232c1e4eb9c6527295f40dc6 Mon Sep 17 00:00:00 2001 From: ProstoDenya01 Date: Thu, 26 Mar 2026 20:08:17 +0300 Subject: [PATCH 26/45] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BD=D1=83=D0=B6=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D1=82=D0=B5=D1=80=D0=B8=D1=8F=D0=BC.=20?= =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/GroupController.java | 12 +- .../controller/ScheduleDataController.java | 106 ++++++++++++++++-- .../magistr/app/dto/CreateGroupRequest.java | 9 ++ .../app/dto/CreateScheduleDataRequest.java | 8 +- .../com/magistr/app/dto/GroupResponse.java | 8 +- .../com/magistr/app/dto/ScheduleResponse.java | 83 ++++++++------ .../com/magistr/app/model/LessonType.java | 31 +++++ .../com/magistr/app/model/ScheduleData.java | 9 +- .../com/magistr/app/model/SemesterType.java | 6 + .../com/magistr/app/model/StudentGroup.java | 11 ++ .../app/repository/LessonTypesRepository.java | 7 ++ .../repository/ScheduleDataRepository.java | 5 + .../db/migration/V2__editScheduleData.sql | 29 +++++ frontend/admin/js/views/department.js | 30 +++-- 14 files changed, 293 insertions(+), 61 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/model/LessonType.java create mode 100644 backend/src/main/java/com/magistr/app/model/SemesterType.java create mode 100644 backend/src/main/java/com/magistr/app/repository/LessonTypesRepository.java create mode 100644 backend/src/main/resources/db/migration/V2__editScheduleData.sql 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 fbb7590..c5eb562 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -46,7 +46,8 @@ public class GroupController { g.getEducationForm().getId(), g.getEducationForm().getName(), g.getDepartmentId(), - g.getCourse() + g.getCourse(), + g.getSpecialityCode() )) .toList(); logger.info("Получено {} групп", response.size()); @@ -114,6 +115,11 @@ public class GroupController { 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()) { @@ -126,6 +132,7 @@ public class GroupController { group.setEducationForm(efOpt.get()); group.setDepartmentId(request.getDepartmentId()); group.setCourse(request.getCourse()); + group.setSpecialityCode(request.getSpecialityCode()); groupRepository.save(group); logger.info("Группа успешно создана с ID - {}", group.getId()); @@ -137,7 +144,8 @@ public class GroupController { group.getEducationForm().getId(), group.getEducationForm().getName(), group.getDepartmentId(), - group.getCourse())); + group.getCourse(), + group.getSpecialityCode())); } catch (Exception e ) { logger.error("Ошибка при создании группы: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java index d289f86..6be2983 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java @@ -1,30 +1,43 @@ package com.magistr.app.controller; -import com.magistr.app.model.Department; -import com.magistr.app.model.ScheduleData; -import com.magistr.app.repository.DepartmentRepository; -import com.magistr.app.repository.ScheduleDataRepository; +import com.magistr.app.dto.ScheduleResponse; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Collections; import java.util.List; +import java.util.Map; @RestController -@RequestMapping("/api/scheduledata") +@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) { + 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 + @GetMapping("/allList") public List getAllScheduleDataList() { logger.info("Получен запрос на получение списка данных расписаний"); try { @@ -51,4 +64,83 @@ public class ScheduleDataController { 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("Неизвестно"); + + Integer groupCourse = groupRepository.findById(s.getGroupId()) + .map(StudentGroup::getCourse) + .orElse(null); + + String specialityCode = "Неизвестно"; + StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null); + if (group != null) { + Long specialityId = group.getSpecialityCode(); + specialityCode = specialtiesRepository.findById(specialityId). + map(Speciality::getSpecialityCode) + .orElse("Неизвестно"); + } + + String subjectName = subjectRepository.findById(s.getSubjectsId()) + .map(Subject::getName) + .orElse("Неизвестно"); + + String lessonType = lessonTypesRepository.findById(s.getLessonTypeId()) + .map(LessonType::getLessonType) + .orElse("Неизвестно"); + + String teacherName = userRepository.findById(s.getTeacherId()) + .map(User::getFullName) + .orElse("Неизвестно"); + + String teacherjobTitle = userRepository.findById(s.getTeacherId()) + .map(User::getJobTitle) + .orElse("Неизвестно"); + + return new ScheduleResponse( + s.getId(), + s.getDepartmentId(), + specialityCode, + s.getSemester(), + groupName, + groupCourse, + subjectName, + lessonType, + s.getNumberOfHours(), + s.getDivision(), + teacherName, + teacherjobTitle, + s.getSemesterType(), + s.getPeriod()); + } + ) + .toList(); + logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId); + return ResponseEntity.ok(response); + } catch (Exception e) { + logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e); + throw e; + } + } } \ No newline at end of file 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 5007602..94561b0 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -7,6 +7,7 @@ public class CreateGroupRequest { private Long educationFormId; private Long departmentId; private Integer course; + private Long specialityCode; public String getName() { return name; @@ -47,4 +48,12 @@ public class CreateGroupRequest { public void setCourse(Integer course) { this.course = course; } + + 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 index 858436b..016958a 100644 --- a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java @@ -1,5 +1,7 @@ package com.magistr.app.dto; +import com.magistr.app.model.SemesterType; + public class CreateScheduleDataRequest { private Long id; private Long departmentId; @@ -10,7 +12,7 @@ public class CreateScheduleDataRequest { private Long numberOfHours; private Boolean isDivision; private Long teacherId; - private String semesterType; + private SemesterType semesterType; private String period; public Long getId() { @@ -85,11 +87,11 @@ public class CreateScheduleDataRequest { this.teacherId = teacherId; } - public String getSemesterType() { + public SemesterType getSemesterType() { return semesterType; } - public void setSemesterType(String semesterType) { + public void setSemesterType(SemesterType semesterType) { this.semesterType = semesterType; } 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 9d0216b..b93cd93 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -9,8 +9,9 @@ public class GroupResponse { private String educationFormName; private Long departmentId; private Integer course; + private Long specialityCode; - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course) { + public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Long specialityCode) { this.id = id; this.name = name; this.groupSize = groupSize; @@ -18,6 +19,7 @@ public class GroupResponse { this.educationFormName = educationFormName; this.departmentId = departmentId; this.course = course; + this.specialityCode = specialityCode; } public Long getId() { @@ -47,4 +49,8 @@ public class GroupResponse { public Integer getCourse() { return course; } + + public Long getSpecialityCode() { + return specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java index 7d906c3..67041f3 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java @@ -1,22 +1,30 @@ package com.magistr.app.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import com.magistr.app.model.SemesterType; @JsonInclude(JsonInclude.Include.NON_NULL) public class ScheduleResponse { private Long id; + private String specialityCode; private Long departmentId; private Long semester; private Long groupId; + private String groupName; + private Integer groupCourse; private Long subjectsId; + private String subjectName; private Long lessonTypeId; + private String lessonType; private Long numberOfHours; private Boolean isDivision; private Long teacherId; - private String semesterType; + private String teacherName; + private String teacherJobTitle; + private SemesterType semesterType; private String period; - public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { + public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean isDivision, Long teacherId, SemesterType semesterType, String period) { this.id = id; this.departmentId = departmentId; this.semester = semester; @@ -30,91 +38,92 @@ public class ScheduleResponse { this.period = period; } + public ScheduleResponse(Long id, Long departmentId, String specialityCode, Long semester, String groupName, Integer groupCourse, String subjectName, String lessonType, Long numberOfHours, Boolean isDivision, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) { + this.id = id; + this.departmentId = departmentId; + this.specialityCode = specialityCode; + this.semester = semester; + this.groupName = groupName; + this.groupCourse = groupCourse; + this.subjectName = subjectName; + this.lessonType = lessonType; + this.numberOfHours = numberOfHours; + this.isDivision = isDivision; + this.teacherName = teacherName; + this.teacherJobTitle = teacherJobTitle; + this.semesterType = semesterType; + this.period = period; + } + public Long getId() { return id; } - public void setId(Long id) { - this.id = id; + public String getSpecialityCode() { + return specialityCode; } public Long getDepartmentId() { return departmentId; } - public void setDepartmentId(Long departmentId) { - this.departmentId = departmentId; - } - public Long getSemester() { return semester; } - public void setSemester(Long semester) { - this.semester = semester; - } - public Long getGroupId() { return groupId; } - public void setGroupId(Long groupId) { - this.groupId = groupId; + public String getGroupName() { + return groupName; + } + + public Integer getGroupCourse() { + return groupCourse; } public Long getSubjectsId() { return subjectsId; } - public void setSubjectsId(Long subjectsId) { - this.subjectsId = subjectsId; + public String getSubjectName() { + return subjectName; } public Long getLessonTypeId() { return lessonTypeId; } - public void setLessonTypeId(Long lessonTypeId) { - this.lessonTypeId = lessonTypeId; + public String getLessonType() { + return lessonType; } public Long getNumberOfHours() { return numberOfHours; } - public void setNumberOfHours(Long numberOfHours) { - this.numberOfHours = numberOfHours; - } - public Boolean getDivision() { return isDivision; } - public void setDivision(Boolean division) { - isDivision = division; - } - public Long getTeacherId() { return teacherId; } - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; + public String getTeacherName() { + return teacherName; } - public String getSemesterType() { + public String getTeacherJobTitle() { + return teacherJobTitle; + } + + public SemesterType getSemesterType() { return semesterType; } - public void setSemesterType(String 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/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 index ecc7909..02c2c6b 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleData.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleData.java @@ -34,15 +34,16 @@ public class ScheduleData { @Column(name="teacher_id", nullable = false) private Long teacherId; + @Enumerated(EnumType.STRING) @Column(name="semester_type", nullable = false) - private String semesterType; + private SemesterType semesterType; @Column(name="period", nullable = false) private String period; public ScheduleData() {} - public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { + public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, SemesterType semesterType, String period) { this.id = id; this.departmentId = departmentId; this.semester = semester; @@ -128,11 +129,11 @@ public class ScheduleData { this.teacherId = teacherId; } - public String getSemesterType() { + public SemesterType getSemesterType() { return semesterType; } - public void setSemesterType(String semesterType) { + public void setSemesterType(SemesterType semesterType) { this.semesterType = semesterType; } 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/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index ed1ca2b..f901887 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -26,6 +26,9 @@ public class StudentGroup { @Column(name = "course", nullable = false) private Integer course; + @Column(name="specialty_code", nullable = false) + private Long specialityCode; + public StudentGroup() { } @@ -76,4 +79,12 @@ public class StudentGroup { public void setCourse(Integer course) { this.course = course; } + + public Long getSpecialityCode() { + return specialityCode; + } + + public void setSpecialityCode(Long specialityCode) { + this.specialityCode = specialityCode; + } } 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 index 82443f6..175572b 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java @@ -1,7 +1,12 @@ 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); } diff --git a/backend/src/main/resources/db/migration/V2__editScheduleData.sql b/backend/src/main/resources/db/migration/V2__editScheduleData.sql new file mode 100644 index 0000000..a99481c --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__editScheduleData.sql @@ -0,0 +1,29 @@ +-- ========================================== +-- Редактирование учебных групп +-- ========================================== + +ALTER TABLE student_groups +ADD COLUMN IF NOT EXISTS specialty_code INT REFERENCES specialties(id); + +UPDATE student_groups +SET specialty_code = 1 +WHERE specialty_code IS NULL; + +ALTER TABLE student_groups +ALTER COLUMN specialty_code SET NOT NULL; + +-- ========================================== +-- Редактирование данных для расписания +-- ========================================== + +INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) +VALUES (1, 1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), + (2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (3, 5, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'), + (2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), + (1, 1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), + (1, 2, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), + (1, 3, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), + (1, 4, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); \ No newline at end of file diff --git a/frontend/admin/js/views/department.js b/frontend/admin/js/views/department.js index 9b0a1c0..63e6388 100644 --- a/frontend/admin/js/views/department.js +++ b/frontend/admin/js/views/department.js @@ -86,8 +86,8 @@ export async function initDepartment() { Дисциплина Вид занятий Часов в неделю - Аудитория - Фамилия преподавателя + Деление на подгруппы + Преподаватель @@ -107,14 +107,30 @@ export async function initDepartment() { return schedule.map(r => ` - ${escapeHtml(r.specialty || '-')} - ${escapeHtml(r.courseSemester || '-')} + ${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.hours || '-')} - ${escapeHtml(r.classroom || '-')} - ${escapeHtml(r.teacherName || '-')} + ${escapeHtml(r.numberOfHours || '-')} + + ${r.division === true ? '✓' : (r.division === false ? '' : escapeHtml(''))} + + + ${(() => { + const jobTitle = r.teacherJobTitle || '-'; + const teacherName = r.teacherName || '-'; + if (jobTitle === '-' && teacherName === '-') return '-'; + return `${jobTitle}, ${teacherName}`; + })()} + `).join(''); } From 6be8db0cd0926711e128290afc0bb82386f78c71 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 27 Mar 2026 15:03:52 +0300 Subject: [PATCH 27/45] =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA,=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D1=83=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B2=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/1.md | 6 - .agents/skills/SKILL.md | 29 -- .../SKILL.md} | 0 frontend/admin/css/components.css | 41 +++ frontend/admin/css/layout.css | 127 ++++++++- frontend/admin/index.html | 49 +++- frontend/admin/js/main.js | 53 +++- frontend/admin/settings/css/layout.css | 255 ++++++++++++++++++ frontend/admin/settings/css/main.css | 153 +++++++++++ frontend/admin/settings/index.html | 89 ++++++ frontend/admin/settings/js/main.js | 98 +++++++ frontend/admin/settings/views/general.html | 11 + 12 files changed, 859 insertions(+), 52 deletions(-) delete mode 100644 .agents/rules/1.md delete mode 100644 .agents/skills/SKILL.md rename .agents/skills/{AutoUpdateDocs.md => auto-update-docs/SKILL.md} (100%) create mode 100644 frontend/admin/settings/css/layout.css create mode 100644 frontend/admin/settings/css/main.css create mode 100644 frontend/admin/settings/index.html create mode 100644 frontend/admin/settings/js/main.js create mode 100644 frontend/admin/settings/views/general.html diff --git a/.agents/rules/1.md b/.agents/rules/1.md deleted file mode 100644 index 513a1d3..0000000 --- a/.agents/rules/1.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -trigger: always_on -glob: -description: ---- - diff --git a/.agents/skills/SKILL.md b/.agents/skills/SKILL.md deleted file mode 100644 index ec02af3..0000000 --- a/.agents/skills/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ -Контекст проекта: - -Backend: Java 17, Spring Framework. Учитывай возможности этой версии языка и стандарты фреймворка. - -Frontend: HTML, CSS, JavaScript. - -Правила написания кода и комментариев: - -Проверка: Перед написанием кода изучи проект, его структуру и используемые технологии. Не предлагай решения, которые не соответствуют текущей архитектуре или стеку. - -Язык: Все комментарии и объяснения должны быть строго на русском языке. - -Комментирование кода: Оставляй комментарии, объясняющие, за что отвечает та или иная часть кода. Перед крупными или смысловыми блоками обязательно ставь поясняющие метки (например: ``, /* таблица subjects */, // логика обработки subjects). - -Обоснование решений: При написании нового кода кратко и максимально понятно объясняй, почему мы используем именно это решение, а не другое. - -Современные подходы: На фронтенде используй самые современные и актуальные подходы (например, Flexbox, CSS Grid, семантические теги). - -Правила работы с ошибками (Обучающий режим): - -Если ты находишь ошибку в моем коде, не пиши сразу готовый исправленный код. - -Дай мне точную подсказку, чтобы я мог сам найти и исправить баг (например: "У тебя не закрыт тег в 15 строке", "Ты забыл поставить аннотацию в контроллере Spring" или "Проверь отступы в таком-то классе"). Моя цель — научиться. - -Правила работы с дизайном (UI/UX): - -Перед добавлением новых стилей всегда сначала изучай, какие стили уже используются в проекте, чтобы сохранять единообразие. - -Если ты видишь, что текущий дизайн откровенно плох, нелогичен или устарел — смело предлагай свои идеи по улучшению (цветовая палитра, отступы, шрифты). Я открыт к предложениям по улучшению визуала. \ No newline at end of file diff --git a/.agents/skills/AutoUpdateDocs.md b/.agents/skills/auto-update-docs/SKILL.md similarity index 100% rename from .agents/skills/AutoUpdateDocs.md rename to .agents/skills/auto-update-docs/SKILL.md diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 7c24c52..466d3fc 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -753,4 +753,45 @@ tbody tr:hover { display: flex; align-items: center; gap: 8px; +} + +/* ===== Theme Toggle Button ===== */ +.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; } \ No newline at end of file diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index d6386ac..7b32ed7 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -19,6 +19,27 @@ .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 { @@ -99,7 +120,7 @@ border-top: 1px solid var(--bg-card-border); } -.btn-logout { +.btn-settings { width: 100%; display: flex; align-items: center; @@ -116,16 +137,110 @@ position: relative; } -.btn-logout:hover { - background: rgba(248, 113, 113, 0.1); +.btn-settings:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.settings-chevron { + margin-left: auto; + transition: transform 0.3s ease; + flex-shrink: 0; +} + +.settings-dropdown.open .settings-chevron { + transform: rotate(180deg); +} + +/* Settings Dropdown Menu */ +.settings-dropdown { + position: relative; +} + +.settings-menu { + position: absolute; + bottom: calc(100% + 0.5rem); + left: 0; + right: 0; + background: rgba(15, 23, 42, 0.97); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.35); + padding: 0.4rem; + z-index: 200; + opacity: 0; + visibility: hidden; + transform: translateY(8px); + transition: opacity 0.25s ease, visibility 0.25s ease, transform 0.25s ease; +} + +[data-theme="light"] .settings-menu { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.1); +} + +.settings-dropdown.open .settings-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.settings-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border: none; + border-radius: 8px; + background: none; + color: var(--text-primary); + font-family: inherit; + font-size: 0.88rem; + cursor: pointer; + text-decoration: none; + transition: background 0.2s ease; + width: 100%; +} + +.settings-menu-item:hover { + background: var(--bg-hover); +} + +.settings-menu-item--danger { color: var(--error); } +.settings-menu-item--danger:hover { + background: rgba(248, 113, 113, 0.1); +} + +.settings-menu-divider { + height: 1px; + background: var(--bg-card-border); + margin: 0.25rem 0.5rem; +} + /* ===== 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 { + transform: translateX(-100%); + } + .main.sidebar-collapsed { + margin-left: 0; + } + .main.sidebar-collapsed .menu-toggle { + display: block; + } } .topbar { @@ -180,7 +295,9 @@ backdrop-filter: blur(2px); z-index: 9; opacity: 0; - transition: opacity var(--transition); + visibility: hidden; + pointer-events: none; + transition: opacity var(--transition), visibility var(--transition); } /* ===== Responsive Mobile ===== */ @@ -212,5 +329,7 @@ .sidebar-overlay.open { opacity: 1; + visibility: visible; + pointer-events: auto; } } \ No newline at end of file diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 158ee8d..868315e 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -36,6 +36,12 @@ Magistr
+
diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index dbbd55a..fc86867 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -1,4 +1,7 @@ -import './otel.js'; +// OTel: загружаем только на продакшене (не на localhost) +if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { + import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); +} import { isAuthenticatedAsAdmin } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; @@ -37,7 +40,9 @@ 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 btnLogout = document.getElementById('btn-logout'); +const main = document.querySelector('.main'); // Initial auth check if (!isAuthenticatedAsAdmin()) { @@ -48,16 +53,56 @@ if (!isAuthenticatedAsAdmin()) { applyRippleEffect(); closeAllDropdownsOnOutsideClick(); -// Menu Toggle +// Init sidebar state from localStorage on load +if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { + sidebar.classList.add('collapsed'); + main.classList.add('sidebar-collapsed'); +} + +// Menu Toggle (Hamburger) menuToggle.addEventListener('click', () => { - sidebar.classList.toggle('open'); - sidebarOverlay.classList.toggle('open'); + 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'); }); +// Settings Dropdown +const settingsDropdown = document.getElementById('settings-dropdown'); +const btnSettings = document.getElementById('btn-settings'); + +btnSettings.addEventListener('click', (e) => { + e.stopPropagation(); + settingsDropdown.classList.toggle('open'); +}); + +document.addEventListener('click', (e) => { + if (!settingsDropdown.contains(e.target)) { + settingsDropdown.classList.remove('open'); + } +}); + // Logout btnLogout.addEventListener('click', () => { localStorage.removeItem('token'); diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css new file mode 100644 index 0000000..ff48f7e --- /dev/null +++ b/frontend/admin/settings/css/layout.css @@ -0,0 +1,255 @@ +/* ===== 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: 10; + transition: 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); +} + +.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 { + transform: translateX(-100%); + } + .main.sidebar-collapsed { + margin-left: 0; + } + .main.sidebar-collapsed .menu-toggle { + display: block; + } +} + +.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..4bf476e --- /dev/null +++ b/frontend/admin/settings/index.html @@ -0,0 +1,89 @@ + + + + + + + Настройки — Magistr + + + + + + + + + + + + + + + + +
+
+ +

Загрузка...

+
+ +
+ +
+
+ + + + + + diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js new file mode 100644 index 0000000..59614c8 --- /dev/null +++ b/frontend/admin/settings/js/main.js @@ -0,0 +1,98 @@ +// Settings page main.js + +// Auth check +const token = localStorage.getItem('token'); +const role = localStorage.getItem('role'); +if (!token || role !== 'ADMIN') { + window.location.href = '/'; +} + +// 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 @@ +
+
+ + + + +
+

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

+

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

+
From e015758cafd7bce0ae6290b514f432318b103770 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 27 Mar 2026 15:24:29 +0300 Subject: [PATCH 28/45] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/auto-update-docs/SKILL.md | 1 + AGENTS.md | 1 + docs/FRONTEND.md | 89 +++++++++++++++++++----- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/.agents/skills/auto-update-docs/SKILL.md b/.agents/skills/auto-update-docs/SKILL.md index 8910bf4..024c5f9 100644 --- a/.agents/skills/auto-update-docs/SKILL.md +++ b/.agents/skills/auto-update-docs/SKILL.md @@ -30,6 +30,7 @@ description: Автоматическое обновление документ | `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` | diff --git a/AGENTS.md b/AGENTS.md index a6da314..7029eb4 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ magistr/ │ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД) ├── frontend/ # Статические файлы │ ├── admin/ # Интерфейс администратора +│ │ └── settings/ # Страница настроек (отдельный SPA) │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента ├── docs/ # 📖 Документация проекта diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c571a55..2a224ca 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -28,12 +28,14 @@ frontend/ │ │ ├── main.css # CSS-переменные, цвета, типографика │ │ ├── layout.css # Раскладка (sidebar, topbar, content) │ │ ├── components.css # Кнопки, таблицы, карточки, формы -│ │ └── modals.css # Модальные окна +│ │ ├── modals.css # Модальные окна +│ │ ├── department.css # Стили кафедры +│ │ └── departments-data.css # Стили создания кафедры/специальности │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── utils.js # Утилиты -│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия) +│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ └── views/ # Модули представлений │ │ ├── users.js # Управление пользователями │ │ ├── groups.js # Управление группами @@ -42,16 +44,30 @@ frontend/ │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения │ │ ├── schedule.js # Расписание занятий -│ │ └── database.js # Управление тенантами -│ └── views/ # HTML-шаблоны представлений -│ ├── users.html -│ ├── groups.html -│ ├── classrooms.html -│ ├── subjects.html -│ ├── equipments.html -│ ├── edu-forms.html -│ ├── schedule.html -│ └── database.html +│ │ ├── 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 # Просмотр расписания @@ -92,6 +108,17 @@ frontend/ | `subjects` | Дисциплины | `/api/subjects` | | `schedule` | Расписание | `/api/users/lessons` | | `database` | Тенанты | `/api/database` | +| `department` | Кафедры | `/api/departments` | +| `departments-data` | Создание кафедры/специальности | `/api/departments` | + +### Страница настроек (`/admin/settings/`) + +Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью. + +- Доступ: через dropdown «Настройки» в footer боковой панели админки +- Кнопка «Назад в панель» для возврата в `/admin/` +- Текущие вкладки: + - **Общие настройки** — заглушка (в разработке) --- @@ -157,7 +184,7 @@ export function isAuthenticatedAsAdmin() { ### Выход -Кнопка «Выйти» очищает `localStorage` и перенаправляет на `/`. +Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. --- @@ -165,12 +192,14 @@ export function isAuthenticatedAsAdmin() { ### Модульный подход -Стили разделены на 4 файла (порядок подключения важен): +Стили разделены на модульные файлы (порядок подключения важен): 1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема -2. **`layout.css`** — Sidebar, topbar, content area, responsive -3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы +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`** — Стили создания кафедры/специальности ### Темизация @@ -194,10 +223,34 @@ CSS-переменные позволяют поддерживать светл --- +## Боковая панель (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 +- Sidebar скрывается на экранах < 768px, выезжает как overlay - Появляется кнопка-гамбургер (`#menu-toggle`) -- Sidebar выезжает как overlay +- Кнопка-крестик закрывает sidebar на всех устройствах - Таблицы получают горизонтальный скролл From bfdcb58c7dfff0c3b806a3f53f4fa799ff268bbd Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 27 Mar 2026 16:08:44 +0300 Subject: [PATCH 29/45] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + backend/README.md | 77 ------ docs/UI_COMPONENTS.md | 115 +++++++++ frontend/admin/css/components.css | 322 ++++++++++++++++++++++---- frontend/admin/css/layout.css | 34 +-- frontend/admin/js/dropdown.js | 222 ++++++++++++++++++ frontend/admin/js/main.js | 12 + frontend/admin/js/views/equipments.js | 4 +- frontend/admin/settings/js/main.js | 7 + frontend/script.js | 44 ++-- 10 files changed, 684 insertions(+), 154 deletions(-) delete mode 100644 backend/README.md create mode 100644 docs/UI_COMPONENTS.md create mode 100644 frontend/admin/js/dropdown.js diff --git a/AGENTS.md b/AGENTS.md index 7029eb4..2d3d4b2 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,3 +86,4 @@ docker compose logs -f backend | [`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/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/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` для отслеживания изменений `