From 3a25996280e02d9e74a07e87ec61e2fa763eaed2 Mon Sep 17 00:00:00 2001 From: Maksim Skobaro Date: Thu, 5 Jun 2025 06:51:14 +0300 Subject: [PATCH] Improvements --- server/pom.xml | 7 +- .../ru/mskobaro/tdms/TdmsApplication.java | 2 +- .../tdms/business/entity/AuditInfo.java | 21 + .../business/entity/CommissionMemberData.java | 25 + .../tdms/business/entity/Defense.java | 40 ++ .../tdms/business/entity/DiplomaTopic.java | 35 ++ .../entity/DirectionOfPreparation.java | 27 + .../mskobaro/tdms/business/entity/Group.java | 40 ++ .../tdms/business/entity/Participant.java | 67 +++ .../{domain => business}/entity/Role.java | 8 +- .../tdms/business/entity/StudentData.java | 50 ++ .../entity/StudyForm.java} | 18 +- .../mskobaro/tdms/business/entity/Task.java | 41 ++ .../tdms/business/entity/TeacherData.java | 24 + .../{domain => business}/entity/User.java | 36 +- .../exception/AccessDeniedException.java | 8 +- .../exception/BusinessException.java | 4 +- .../exception/NotFoundException.java | 10 +- .../service/AuthenticationService.java | 36 +- .../tdms/business/service/DefenceService.java | 20 + .../business/service/DiplomaTopicService.java | 55 ++ .../tdms/business/service/GroupService.java | 78 +++ .../business/service/ParticipantService.java | 220 ++++++++ .../service/PreparationDirectionService.java | 34 ++ .../service/RoleService.java | 36 +- .../business/service/StudentDataService.java | 29 ++ .../service/SysInfoService.java | 2 +- .../tdms/business/service/TaskService.java | 63 +++ .../business/service/TeacherDataService.java | 30 ++ .../tdms/business/service/UserService.java | 50 ++ .../DiplomaTopicAgreementTaskFields.java | 11 + .../taskfields/MakerCheckerTaskFields.java | 13 + .../business/taskfields/MakerTaskFields.java | 10 + .../tdms/business/taskfields/TaskFields.java | 10 + .../ru/mskobaro/tdms/domain/entity/Group.java | 35 -- .../mskobaro/tdms/domain/entity/Student.java | 68 --- .../mskobaro/tdms/domain/entity/Teacher.java | 36 -- .../domain/service/DiplomaTopicService.java | 9 - .../tdms/domain/service/GroupService.java | 67 --- .../tdms/domain/service/StudentService.java | 57 --- .../tdms/domain/service/UserService.java | 181 ------- .../database/DefenceRepository.java | 23 +- .../database/DiplomaTopicRepository.java | 24 +- .../integration/database/GroupRepository.java | 16 +- .../database/ParticipantRepository.java | 27 + .../PreparationDirectionRepository.java | 13 + .../integration/database/RoleRepository.java | 2 +- .../database/StudentDataRepository.java | 39 ++ .../database/StudentRepository.java | 16 - .../integration/database/TaskRepository.java | 21 + .../database/TeacherDataRepository.java | 30 ++ .../database/TeacherRepository.java | 13 - .../integration/database/UserRepository.java | 11 +- .../controller/DefenceController.java | 22 + .../controller/DiplomaTopicController.java | 25 +- .../controller/GroupController.java | 18 +- .../controller/ParticipantController.java | 35 ++ .../PreparationDirectionController.java | 26 + .../controller/StudentController.java | 25 +- .../controller/SysInfoController.java | 2 +- .../controller/TaskController.java | 34 ++ .../controller/TeacherDataController.java | 28 + .../controller/UserController.java | 31 +- .../payload/CommissionMemberDTO.java | 31 ++ .../controller/payload/DefenceDTO.java | 39 ++ .../controller/payload/DiplomaTopicDTO.java | 37 ++ .../{ => controller}/payload/ErrorDTO.java | 2 +- .../controller/payload/GroupDTO.java | 47 ++ .../controller/payload/IdDto.java | 14 + .../{ => controller}/payload/LoginDTO.java | 2 +- .../controller/payload/ParticipantDTO.java | 48 ++ .../payload/ParticipantSaveDTO.java | 64 +++ .../payload/PreparationDirectionDTO.java | 29 ++ .../controller/payload/RoleDTO.java | 19 + .../controller/payload/StudentDataDTO.java | 31 ++ .../controller/payload/TaskDto.java | 33 ++ .../controller/payload/TeacherDataDTO.java | 27 + .../controller/payload/UserDTO.java | 41 ++ .../ApplicationExceptionHandler.java | 8 +- .../presentation/payload/GroupCreateDTO.java | 14 - .../tdms/presentation/payload/GroupDTO.java | 15 - .../presentation/payload/GroupEditDTO.java | 17 - .../presentation/payload/RegistrationDTO.java | 54 -- .../tdms/presentation/payload/RoleDTO.java | 19 - .../tdms/presentation/payload/StudentDTO.java | 47 -- .../tdms/presentation/payload/UserDTO.java | 45 -- .../tdms/system/config/SecurityConfig.java | 112 ++-- .../tdms/system/config/WebConfig.java | 28 + .../web/AccessDeniedExceptionHandler.java | 20 + .../web/DevAuthenticationRequestFilter.java | 46 -- .../tdms/system/web/LoggingRequestFilter.java | 14 +- server/src/main/resources/application.yml | 5 - .../db/migration/V00000__Initial_schema.sql | 280 ++++++++++ .../migration/V00010__Create__role_table.sql | 13 - .../migration/V00020__Create__user_table.sql | 22 - .../V00030__Create__user_role_table.sql | 24 - .../V00040__Create__diploma_topic_table.sql | 11 - .../V00050__Create__teacher_table.sql | 19 - .../migration/V00060__Create__group_table.sql | 22 - .../V00070__Create__student_table.sql | 65 --- ...e.sql => V00500__Insert_default_roles.sql} | 3 +- .../V00510__Insert_administrator.sql | 8 - .../V00510__Insert_system_administrator.sql | 21 + .../V00070__Create__defence_table.sql | 6 +- .../src/main/resources/db/test-data/group.sql | 6 +- .../main/resources/db/test-data/student.sql | 2 +- web/package-lock.json | 140 +++++ web/package.json | 1 + web/pom.xml | 1 - web/src/Application.tsx | 16 +- web/src/components/NotificationContainer.tsx | 12 +- .../components/controls/ReactiveControls.css | 25 + .../components/controls/ReactiveControls.tsx | 451 +++++++++++++++++ .../custom/controls/ReactiveControls.css | 3 - .../custom/controls/ReactiveControls.tsx | 148 ------ web/src/components/custom/layout/Header.tsx | 101 ---- web/src/components/custom/layout/Home.tsx | 9 - .../{custom => data-tables}/DataTable.css | 0 .../{custom => data-tables}/DataTable.tsx | 100 ++-- .../components/defence/DefenceListPage.tsx | 74 +++ .../dictionary/DiplomaTopicList.tsx | 118 +++++ .../dictionary/DiplomaTopicModal.tsx | 180 +++++++ .../dictionary/PreparationDirectionList.tsx | 101 ++++ .../dictionary/PreparationDirectionModal.tsx | 140 +++++ web/src/components/group/AddGroupModal.tsx | 55 -- web/src/components/group/GroupListPage.tsx | 247 +++------ .../components/group/GroupProfileModal.tsx | 230 +++++++++ .../group/GroupTableFilterModal.tsx | 54 ++ .../components/{custom => }/layout/Error.tsx | 0 .../components/{custom => }/layout/Footer.tsx | 10 +- web/src/components/layout/Header.tsx | 118 +++++ web/src/components/layout/Home.tsx | 18 + .../components/{custom => }/layout/Page.tsx | 7 +- .../participant/ParticipantListPage.tsx | 121 +++++ .../participant/ParticipantProfileModal.tsx | 478 ++++++++++++++++++ .../tasks/DiplomaTopicAgreement.tsx | 52 ++ web/src/components/user/StudentProfile.tsx | 131 ----- .../components/user/StudentProfileModal.tsx | 57 --- web/src/components/user/UserListPage.tsx | 96 ---- web/src/components/user/UserLoginModal.tsx | 23 +- web/src/components/user/UserProfile.tsx | 14 - web/src/components/user/UserProfilePage.tsx | 221 -------- .../components/user/UserRegistrationModal.tsx | 182 ------- web/src/favicon.png | Bin 0 -> 97365 bytes web/src/index.css | 10 + web/src/index.html | 1 + web/src/models/IGroup.ts | 5 - web/src/models/authorities.ts | 20 - web/src/models/defence.ts | 10 + web/src/models/diplomaTopic.ts | 11 + web/src/models/{error.ts => errorResponse.ts} | 0 web/src/models/group.ts | 13 + web/src/models/participant.ts | 23 + web/src/models/preparationDirection.ts | 7 + web/src/models/registration.ts | 38 +- web/src/models/roles.ts | 27 + web/src/models/student.ts | 22 - web/src/models/studentData.ts | 13 + web/src/models/teacher.ts | 10 - web/src/models/teacherData.ts | 9 + web/src/models/user.ts | 21 +- web/src/services/NotificationService.ts | 127 +++-- web/src/services/RouterService.ts | 7 +- .../SysInfoService.ts} | 17 +- .../ThinkService.ts} | 13 +- web/src/services/UserService.ts | 72 +++ web/src/store/AppRouterStore.tsx | 18 +- web/src/store/NotificationStore.ts | 98 ---- web/src/store/RootStore.ts | 13 - web/src/store/UserStore.ts | 77 --- web/src/utils/ComponentContext.ts | 16 - web/src/utils/init.ts | 48 +- web/src/utils/modalState.ts | 4 +- web/src/utils/reactive/reactiveValue.ts | 2 +- web/src/utils/reactive/validators.ts | 12 +- web/src/utils/request.ts | 8 +- web/src/utils/tables.ts | 16 +- web/webpack.config.js | 10 + 178 files changed, 5277 insertions(+), 2759 deletions(-) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/AuditInfo.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/CommissionMemberData.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/Defense.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/DiplomaTopic.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/DirectionOfPreparation.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/Group.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/Participant.java rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/entity/Role.java (76%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/StudentData.java rename server/src/main/java/ru/mskobaro/tdms/{domain/entity/DiplomaTopic.java => business/entity/StudyForm.java} (60%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/Task.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/entity/TeacherData.java rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/entity/User.java (55%) rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/exception/AccessDeniedException.java (57%) rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/exception/BusinessException.java (68%) rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/exception/NotFoundException.java (72%) rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/service/AuthenticationService.java (50%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/DefenceService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/DiplomaTopicService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/GroupService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/ParticipantService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/PreparationDirectionService.java rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/service/RoleService.java (56%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/StudentDataService.java rename server/src/main/java/ru/mskobaro/tdms/{domain => business}/service/SysInfoService.java (86%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/TaskService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/TeacherDataService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/service/UserService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/taskfields/DiplomaTopicAgreementTaskFields.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerCheckerTaskFields.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerTaskFields.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/business/taskfields/TaskFields.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/entity/Group.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/entity/Student.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/entity/Teacher.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/service/DiplomaTopicService.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/service/GroupService.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/service/StudentService.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/domain/service/UserService.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/ParticipantRepository.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/PreparationDirectionRepository.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/StudentDataRepository.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/StudentRepository.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/TaskRepository.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherDataRepository.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherRepository.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/DefenceController.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/ParticipantController.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/PreparationDirectionController.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/TaskController.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/TeacherDataController.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/CommissionMemberDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DefenceDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DiplomaTopicDTO.java rename server/src/main/java/ru/mskobaro/tdms/presentation/{ => controller}/payload/ErrorDTO.java (90%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/GroupDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/IdDto.java rename server/src/main/java/ru/mskobaro/tdms/presentation/{ => controller}/payload/LoginDTO.java (94%) create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantSaveDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/PreparationDirectionDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/RoleDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/StudentDataDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TaskDto.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TeacherDataDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/UserDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupCreateDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupEditDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/RegistrationDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/RoleDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/StudentDTO.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/presentation/payload/UserDTO.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/system/config/WebConfig.java create mode 100644 server/src/main/java/ru/mskobaro/tdms/system/web/AccessDeniedExceptionHandler.java delete mode 100644 server/src/main/java/ru/mskobaro/tdms/system/web/DevAuthenticationRequestFilter.java create mode 100644 server/src/main/resources/db/migration/V00000__Initial_schema.sql delete mode 100644 server/src/main/resources/db/migration/V00010__Create__role_table.sql delete mode 100644 server/src/main/resources/db/migration/V00020__Create__user_table.sql delete mode 100644 server/src/main/resources/db/migration/V00030__Create__user_role_table.sql delete mode 100644 server/src/main/resources/db/migration/V00040__Create__diploma_topic_table.sql delete mode 100644 server/src/main/resources/db/migration/V00050__Create__teacher_table.sql delete mode 100644 server/src/main/resources/db/migration/V00060__Create__group_table.sql delete mode 100644 server/src/main/resources/db/migration/V00070__Create__student_table.sql rename server/src/main/resources/db/migration/{V00500__Insert_default_roles__role.sql => V00500__Insert_default_roles.sql} (67%) delete mode 100644 server/src/main/resources/db/migration/V00510__Insert_administrator.sql create mode 100644 server/src/main/resources/db/migration/V00510__Insert_system_administrator.sql create mode 100644 web/src/components/controls/ReactiveControls.css create mode 100644 web/src/components/controls/ReactiveControls.tsx delete mode 100644 web/src/components/custom/controls/ReactiveControls.css delete mode 100644 web/src/components/custom/controls/ReactiveControls.tsx delete mode 100644 web/src/components/custom/layout/Header.tsx delete mode 100644 web/src/components/custom/layout/Home.tsx rename web/src/components/{custom => data-tables}/DataTable.css (100%) rename web/src/components/{custom => data-tables}/DataTable.tsx (76%) create mode 100644 web/src/components/defence/DefenceListPage.tsx create mode 100644 web/src/components/dictionary/DiplomaTopicList.tsx create mode 100644 web/src/components/dictionary/DiplomaTopicModal.tsx create mode 100644 web/src/components/dictionary/PreparationDirectionList.tsx create mode 100644 web/src/components/dictionary/PreparationDirectionModal.tsx delete mode 100644 web/src/components/group/AddGroupModal.tsx create mode 100644 web/src/components/group/GroupProfileModal.tsx create mode 100644 web/src/components/group/GroupTableFilterModal.tsx rename web/src/components/{custom => }/layout/Error.tsx (100%) rename web/src/components/{custom => }/layout/Footer.tsx (75%) create mode 100644 web/src/components/layout/Header.tsx create mode 100644 web/src/components/layout/Home.tsx rename web/src/components/{custom => }/layout/Page.tsx (82%) create mode 100644 web/src/components/participant/ParticipantListPage.tsx create mode 100644 web/src/components/participant/ParticipantProfileModal.tsx create mode 100644 web/src/components/tasks/DiplomaTopicAgreement.tsx delete mode 100644 web/src/components/user/StudentProfile.tsx delete mode 100644 web/src/components/user/StudentProfileModal.tsx delete mode 100644 web/src/components/user/UserListPage.tsx delete mode 100644 web/src/components/user/UserProfile.tsx delete mode 100644 web/src/components/user/UserProfilePage.tsx delete mode 100644 web/src/components/user/UserRegistrationModal.tsx create mode 100644 web/src/favicon.png delete mode 100644 web/src/models/IGroup.ts delete mode 100644 web/src/models/authorities.ts create mode 100644 web/src/models/defence.ts create mode 100644 web/src/models/diplomaTopic.ts rename web/src/models/{error.ts => errorResponse.ts} (100%) create mode 100644 web/src/models/group.ts create mode 100644 web/src/models/participant.ts create mode 100644 web/src/models/preparationDirection.ts create mode 100644 web/src/models/roles.ts delete mode 100644 web/src/models/student.ts create mode 100644 web/src/models/studentData.ts delete mode 100644 web/src/models/teacher.ts create mode 100644 web/src/models/teacherData.ts rename web/src/{store/SysInfoStore.ts => services/SysInfoService.ts} (59%) rename web/src/{store/ThinkStore.ts => services/ThinkService.ts} (70%) create mode 100644 web/src/services/UserService.ts delete mode 100644 web/src/store/NotificationStore.ts delete mode 100644 web/src/store/UserStore.ts diff --git a/server/pom.xml b/server/pom.xml index e945b64..12901ed 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -10,15 +10,14 @@ 0.0.1 - ru.mskobaro server 0.0.1 TDMS::SERVER - 17 - 17 + 23 + 23 UTF-8 1.18.34 @@ -127,6 +126,8 @@ ${lombok.version} + 23 + 23 diff --git a/server/src/main/java/ru/mskobaro/tdms/TdmsApplication.java b/server/src/main/java/ru/mskobaro/tdms/TdmsApplication.java index a3b12d0..96357ab 100644 --- a/server/src/main/java/ru/mskobaro/tdms/TdmsApplication.java +++ b/server/src/main/java/ru/mskobaro/tdms/TdmsApplication.java @@ -15,7 +15,7 @@ public class TdmsApplication { Thread.currentThread().setName("spring-bootstrapper"); ConfigurableApplicationContext ctx = SpringApplication.run(TdmsApplication.class, args); Environment environment = ctx.getEnvironment(); - log.info("Static files location: {}", environment.getProperty("spring.web.resources.static-locations")); + log.info("Server listening on port: {}", environment.getProperty("server.port")); ctx.start(); } } \ No newline at end of file diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/AuditInfo.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/AuditInfo.java new file mode 100644 index 0000000..2e43ca9 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/AuditInfo.java @@ -0,0 +1,21 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Embeddable +@Getter +public class AuditInfo { + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt; + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/CommissionMemberData.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/CommissionMemberData.java new file mode 100644 index 0000000..3e283ee --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/CommissionMemberData.java @@ -0,0 +1,25 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "commission_member_data") +@Getter +@Setter +public class CommissionMemberData { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "partic_id", referencedColumnName = "id") + private Participant participant; + + private String workPlace; + private String workPosition; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/Defense.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/Defense.java new file mode 100644 index 0000000..795a1ee --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/Defense.java @@ -0,0 +1,40 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Entity +@Table(name = "defense") +@Getter +@Setter +public class Defense { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToMany + @JoinTable( + name = "defense_commission", + joinColumns = @JoinColumn(name = "defense_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn(name = "commission_member_data_id", referencedColumnName = "id")) + private List commissionMembers; + + private LocalDate defenseDate; + + @OneToMany(mappedBy = "defense") + private List groups; + + @ManyToMany + @JoinTable( + name = "defense_best_student_works", + joinColumns = @JoinColumn(name = "defense_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn(name = "student_data_id", referencedColumnName = "id")) + private List bestWorks; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/DiplomaTopic.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/DiplomaTopic.java new file mode 100644 index 0000000..e8652d2 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/DiplomaTopic.java @@ -0,0 +1,35 @@ +package ru.mskobaro.tdms.business.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + + +@Getter +@Setter +@ToString +@Entity +@Table(name = "diploma_topic") +public class DiplomaTopic { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name") + private String name; + + @ManyToOne + @JoinColumn(name = "teacher_id") + private TeacherData teacher; + + @ManyToOne + @JoinColumn(name = "direction_of_preparation_id") + private DirectionOfPreparation directionOfPreparation; + + @Embedded + private AuditInfo auditInfo; +} + diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/DirectionOfPreparation.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/DirectionOfPreparation.java new file mode 100644 index 0000000..e39483c --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/DirectionOfPreparation.java @@ -0,0 +1,27 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Entity +@Table(name = "direction_of_preparation") +@Getter +@Setter +public class DirectionOfPreparation { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private String code; + + @OneToMany(mappedBy = "directionOfPreparation") + private List diplomaTopic; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/Group.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/Group.java new file mode 100644 index 0000000..4a93a33 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/Group.java @@ -0,0 +1,40 @@ +package ru.mskobaro.tdms.business.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; + + +@Getter +@Setter +@ToString +@Entity +@Table(name = "`group`") +public class Group { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name") + private String name; + + @OneToMany(mappedBy = "group") + private List students = new ArrayList<>(); + + @ManyToOne + @JoinColumn(name = "direction_of_preparation_id") + private DirectionOfPreparation directionOfPreparation; + + @ManyToOne + @JoinColumn(name = "defense_id") + private Defense defense; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/Participant.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/Participant.java new file mode 100644 index 0000000..dd63780 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/Participant.java @@ -0,0 +1,67 @@ +package ru.mskobaro.tdms.business.entity; + +import io.micrometer.common.util.StringUtils; +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Entity +@Table(name = "participant") +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +public class Participant { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String firstName; + private String lastName; + private String middleName; + + private String email; + + private String numberPhone; + + @OneToOne(mappedBy = "participant") + private User user; + + private boolean deleted; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "participant_role", + joinColumns = @JoinColumn(name = "partic_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")) + private List roles; + + @Embedded + private AuditInfo auditInfo; + + @Transient + public String getFullName() { + return Stream.of(lastName, firstName, middleName) + .filter(StringUtils::isNotBlank) + .collect(Collectors.joining(" ")); + } + + @Transient + public String getShortName() { + StringBuilder builder = new StringBuilder(); + if (StringUtils.isNotBlank(lastName)) { + builder.append(lastName); + } + if (StringUtils.isNotBlank(firstName)) { + builder.append(" ").append(firstName.charAt(0)).append("."); + } + if (StringUtils.isNotBlank(middleName)) { + builder.append(" ").append(middleName.charAt(0)).append("."); + } + return builder.toString().trim(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Role.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/Role.java similarity index 76% rename from server/src/main/java/ru/mskobaro/tdms/domain/entity/Role.java rename to server/src/main/java/ru/mskobaro/tdms/business/entity/Role.java index 496e771..195db27 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Role.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/Role.java @@ -1,14 +1,11 @@ -package ru.mskobaro.tdms.domain.entity; +package ru.mskobaro.tdms.business.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; +import lombok.*; import org.springframework.security.core.GrantedAuthority; @@ -18,6 +15,7 @@ import org.springframework.security.core.GrantedAuthority; @AllArgsConstructor @NoArgsConstructor @Table(name = "`role`") +@EqualsAndHashCode(of = "id") public class Role implements GrantedAuthority { @Id @Column(name = "id") diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/StudentData.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/StudentData.java new file mode 100644 index 0000000..dde5f67 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/StudentData.java @@ -0,0 +1,50 @@ +package ru.mskobaro.tdms.business.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import ru.mskobaro.tdms.integration.database.TeacherDataRepository; + + +@Getter +@Setter +@ToString(exclude = "group") +@Entity +@Table(name = "student_data") +public class StudentData { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "partic_id") + private Participant participant; + + @ManyToOne + @JoinColumn(name = "group_id") + private Group group; + + @ManyToOne + @JoinColumn(name = "study_form_id") + private StudyForm form; + + private Integer protectionOrder; + private Integer protectionDay; + + private Integer markComment; + private Integer markPractice; + + @ManyToOne + @JoinColumn(name = "curator_id") + private TeacherData curator; + + @ManyToOne + @JoinColumn(name = "diploma_topic_id") + private DiplomaTopic diplomaTopic; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/DiplomaTopic.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/StudyForm.java similarity index 60% rename from server/src/main/java/ru/mskobaro/tdms/domain/entity/DiplomaTopic.java rename to server/src/main/java/ru/mskobaro/tdms/business/entity/StudyForm.java index 2675a87..98ff818 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/DiplomaTopic.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/StudyForm.java @@ -1,23 +1,21 @@ -package ru.mskobaro.tdms.domain.entity; - +package ru.mskobaro.tdms.business.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -import lombok.ToString; - +@Entity +@Table(name = "study_form") @Getter @Setter -@ToString -@Entity -@Table(name = "diploma_topic") -public class DiplomaTopic { +public class StudyForm { @Id - @Column(name = "id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "name") private String name; -} + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/Task.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/Task.java new file mode 100644 index 0000000..102a4f2 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/Task.java @@ -0,0 +1,41 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import ru.mskobaro.tdms.business.taskfields.TaskFields; + +@Entity +@Table(name = "task") +@NoArgsConstructor +@Getter +@Setter +public class Task { + public enum Type { + DIPLOMA_TOPIC_AGREEMENT, + } + + public enum Status { + WAIT_FOR_TOPIC_AGREEMENT, + DONE, + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private Type type; + + @Enumerated(EnumType.STRING) + private Status status; + + @JdbcTypeCode(SqlTypes.JSON) + private TaskFields fields; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/entity/TeacherData.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/TeacherData.java new file mode 100644 index 0000000..6c7f0a7 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/TeacherData.java @@ -0,0 +1,24 @@ +package ru.mskobaro.tdms.business.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "teacher_data") +@Getter +@Setter +public class TeacherData { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "participant_id", referencedColumnName = "id") + private Participant participant; + + private String degree; + + @Embedded + private AuditInfo auditInfo; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/User.java b/server/src/main/java/ru/mskobaro/tdms/business/entity/User.java similarity index 55% rename from server/src/main/java/ru/mskobaro/tdms/domain/entity/User.java rename to server/src/main/java/ru/mskobaro/tdms/business/entity/User.java index 0e1f039..c98dcda 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/User.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/entity/User.java @@ -1,4 +1,4 @@ -package ru.mskobaro.tdms.domain.entity; +package ru.mskobaro.tdms.business.entity; import jakarta.persistence.*; @@ -6,13 +6,10 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.time.ZonedDateTime; import java.util.Collection; import java.util.List; @@ -32,33 +29,26 @@ public class User implements UserDetails { private String login; @Column(name = "password") private String password; - @Column(name = "full_name") - private String fullName; - @Column(name = "email") - private String email; - @Column(name = "number_phone") - private String numberPhone; - @Column(name = "created_at") - @CreationTimestamp - private ZonedDateTime createdAt; - @Column(name = "updated_at") - @UpdateTimestamp - private ZonedDateTime updatedAt; - @ManyToMany - @JoinTable( - name = "user_role", - joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), - inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")) - private List roles; + @OneToOne + @JoinColumn(name = "partic_id") + private Participant participant; + + @Embedded + private AuditInfo auditInfo; @Override public Collection getAuthorities() { - return roles.stream() + return participant.getRoles().stream() .map(Role::getAuthority) .map(SimpleGrantedAuthority::new) .toList(); } + @Override + public boolean isEnabled() { + return !participant.isDeleted(); + } + @Override public String getUsername() { return login; diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/exception/AccessDeniedException.java b/server/src/main/java/ru/mskobaro/tdms/business/exception/AccessDeniedException.java similarity index 57% rename from server/src/main/java/ru/mskobaro/tdms/domain/exception/AccessDeniedException.java rename to server/src/main/java/ru/mskobaro/tdms/business/exception/AccessDeniedException.java index 606a9d1..d68250c 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/exception/AccessDeniedException.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/exception/AccessDeniedException.java @@ -1,12 +1,16 @@ -package ru.mskobaro.tdms.domain.exception; +package ru.mskobaro.tdms.business.exception; -import ru.mskobaro.tdms.presentation.payload.ErrorDTO; +import ru.mskobaro.tdms.presentation.controller.payload.ErrorDTO; public class AccessDeniedException extends BusinessException { public AccessDeniedException() { super("Access denied"); } + public AccessDeniedException(String message) { + super(message); + } + @Override public ErrorDTO.ErrorCode getErrorCode() { return ErrorDTO.ErrorCode.ACCESS_DENIED; diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/exception/BusinessException.java b/server/src/main/java/ru/mskobaro/tdms/business/exception/BusinessException.java similarity index 68% rename from server/src/main/java/ru/mskobaro/tdms/domain/exception/BusinessException.java rename to server/src/main/java/ru/mskobaro/tdms/business/exception/BusinessException.java index 1cc9574..de2b01c 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/exception/BusinessException.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/exception/BusinessException.java @@ -1,6 +1,6 @@ -package ru.mskobaro.tdms.domain.exception; +package ru.mskobaro.tdms.business.exception; -import ru.mskobaro.tdms.presentation.payload.ErrorDTO; +import ru.mskobaro.tdms.presentation.controller.payload.ErrorDTO; public class BusinessException extends RuntimeException { public BusinessException(String message) { diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/exception/NotFoundException.java b/server/src/main/java/ru/mskobaro/tdms/business/exception/NotFoundException.java similarity index 72% rename from server/src/main/java/ru/mskobaro/tdms/domain/exception/NotFoundException.java rename to server/src/main/java/ru/mskobaro/tdms/business/exception/NotFoundException.java index 278081b..afd2631 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/exception/NotFoundException.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/exception/NotFoundException.java @@ -1,18 +1,18 @@ -package ru.mskobaro.tdms.domain.exception; +package ru.mskobaro.tdms.business.exception; -import ru.mskobaro.tdms.presentation.payload.ErrorDTO; +import ru.mskobaro.tdms.presentation.controller.payload.ErrorDTO; public class NotFoundException extends BusinessException { public NotFoundException(Class entityClass, Object id) { - super(entityClass.getSimpleName() + " с идентификатором " + id + " не наеден"); + super(entityClass.getSimpleName() + " с идентификатором " + id + " не существует"); } public NotFoundException(Class entityClass) { - super(entityClass.getSimpleName() + " не найден"); + super(entityClass.getSimpleName() + " не существует"); } public NotFoundException() { - super("Не найдено"); + super("Не существует"); } public static void throwIfNull(Object object) { diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/AuthenticationService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/AuthenticationService.java similarity index 50% rename from server/src/main/java/ru/mskobaro/tdms/domain/service/AuthenticationService.java rename to server/src/main/java/ru/mskobaro/tdms/business/service/AuthenticationService.java index 4080bc4..6bdc862 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/AuthenticationService.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/AuthenticationService.java @@ -1,4 +1,4 @@ -package ru.mskobaro.tdms.domain.service; +package ru.mskobaro.tdms.business.service; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -6,10 +6,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; + @Service @Slf4j public class AuthenticationService { @@ -17,25 +21,41 @@ public class AuthenticationService { private HttpServletRequest request; @Autowired private AuthenticationManager authenticationManager; + @Autowired + private SessionRegistry sessionRegistry; public void logout() { - log.info("Logging out user: {}", SecurityContextHolder.getContext().getAuthentication().getName()); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return; + } + + String username = authentication.getName(); + HttpSession session = request.getSession(false); - if(session != null) { + if (session != null) { session.invalidate(); } + SecurityContextHolder.clearContext(); - log.info("User logged out"); + + log.info("User {} logged out", username); + } + + public void logout(String username) { + sessionRegistry.getAllSessions(username, false).forEach(session -> { + log.info("Invalidating session for user {}: {}", username, session.getSessionId()); + session.expireNow(); + sessionRegistry.removeSessionInformation(session.getSessionId()); + }); } @Transactional public void login(String username, String password) { - log.info("Logging in user: {}, ip: {}", username, request.getRemoteAddr()); - var token = new UsernamePasswordAuthenticationToken(username, password); var authenticated = authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(authenticated); - - log.info("User {} logged in", username); + request.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext()); + log.info("User {} logged in, ip: {}", username, request.getRemoteAddr()); } } diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/DefenceService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/DefenceService.java new file mode 100644 index 0000000..1510eca --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/DefenceService.java @@ -0,0 +1,20 @@ +package ru.mskobaro.tdms.business.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.mskobaro.tdms.integration.database.DefenceRepository; +import ru.mskobaro.tdms.presentation.controller.payload.DefenceDTO; + +import java.util.List; + +@Service +@Transactional +public class DefenceService { + @Autowired + private DefenceRepository defenceRepository; + + public List getAllDefences() { + return defenceRepository.findAll().stream().map(DefenceDTO::from).toList(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/DiplomaTopicService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/DiplomaTopicService.java new file mode 100644 index 0000000..af341e7 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/DiplomaTopicService.java @@ -0,0 +1,55 @@ +package ru.mskobaro.tdms.business.service; + +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.mskobaro.tdms.business.entity.DiplomaTopic; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; +import ru.mskobaro.tdms.business.entity.TeacherData; +import ru.mskobaro.tdms.integration.database.DiplomaTopicRepository; +import ru.mskobaro.tdms.integration.database.PreparationDirectionRepository; +import ru.mskobaro.tdms.integration.database.TeacherDataRepository; +import ru.mskobaro.tdms.presentation.controller.payload.DiplomaTopicDTO; + +import java.util.List; + +@Service +@Transactional +public class DiplomaTopicService { + @Autowired + private DiplomaTopicRepository diplomaTopicRepository; + @Autowired + private TeacherDataRepository teacherDataRepository; + @Autowired + private PreparationDirectionRepository preparationDirectionRepository; + + public List findAll() { + return diplomaTopicRepository.findAll(); + } + + public void save(DiplomaTopicDTO diplomaTopicDTO) { + boolean isEdit = diplomaTopicDTO.getId() != null; + DiplomaTopic diplomaTopic; + if (isEdit) { + diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicDTO.getId()); + } else { + diplomaTopic = new DiplomaTopic(); + } + + diplomaTopic.setName(diplomaTopicDTO.getName()); + if (diplomaTopicDTO.getTeacher() != null) { + TeacherData teacherData = teacherDataRepository.findByIdThrow(diplomaTopicDTO.getTeacher().getId()); + diplomaTopic.setTeacher(teacherData); + } + if (diplomaTopicDTO.getPreparationDirection() != null) { + DirectionOfPreparation directionOfPreparation = preparationDirectionRepository.findByIdThrow(diplomaTopicDTO.getPreparationDirection().getId()); + diplomaTopic.setDirectionOfPreparation(directionOfPreparation); + } + + diplomaTopicRepository.save(diplomaTopic); + } + + public List findAllForStudent(Long studentId) { + return diplomaTopicRepository.findAllForStudentId(studentId); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/GroupService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/GroupService.java new file mode 100644 index 0000000..9518b84 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/GroupService.java @@ -0,0 +1,78 @@ +package ru.mskobaro.tdms.business.service; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; +import ru.mskobaro.tdms.business.entity.Group; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.exception.BusinessException; +import ru.mskobaro.tdms.integration.database.GroupRepository; +import ru.mskobaro.tdms.integration.database.PreparationDirectionRepository; +import ru.mskobaro.tdms.integration.database.StudentDataRepository; +import ru.mskobaro.tdms.presentation.controller.payload.GroupDTO; +import ru.mskobaro.tdms.presentation.controller.payload.StudentDataDTO; + +import java.util.Collection; +import java.util.List; + +@Service +@Transactional +@Slf4j +public class GroupService { + @Autowired + private GroupRepository groupRepository; + @Autowired + private RoleService roleService; + @Autowired + private StudentDataRepository studentDataRepository; + @Autowired + private PreparationDirectionRepository preparationDirectionRepository; + + public Collection getAllGroups() { + List groups = groupRepository.findAll(); + return groups.stream().map(g -> GroupDTO.from(g, true)).toList(); + } + + public void save(@Valid GroupDTO groupDTO) { + boolean editMode = groupDTO.getId() != null; + log.info("Saving group: {}. Edit?: {}", groupDTO, editMode); + if (!editMode && groupRepository.existsByName(groupDTO.getName())) { + throw new BusinessException("Группа с именем " + groupDTO.getName() + " уже существует"); + } + + Group group = editMode ? groupRepository.findByIdThrow(groupDTO.getId()) : new Group(); + group.setName(groupDTO.getName()); + + studentDataRepository.findAllByGroup_Id(group.getId()).forEach(s -> { + s.setGroup(null); + studentDataRepository.save(s); + }); + group.getStudents().clear(); + + List studentIds = groupDTO.getStudents().stream().map(StudentDataDTO::getId).toList(); + if (!CollectionUtils.isEmpty(studentIds)) { + List students = studentDataRepository.findAllById(studentIds); + students.stream() + .filter(s -> roleService.isParticInAuthority(s.getParticipant(), RoleService.Authority.STUDENT)) + .forEach(s -> { + group.getStudents().add(s); + s.setGroup(group); + }); + } + + if (groupDTO.getPreparationDirection() != null) { + DirectionOfPreparation directionOfPreparation = preparationDirectionRepository.findByIdThrow(groupDTO.getPreparationDirection().getId()); + group.setDirectionOfPreparation(directionOfPreparation); + } + + groupRepository.save(group); + } + + public void deleteGroup(Long groupId) { + groupRepository.deleteById(groupId); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/ParticipantService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/ParticipantService.java new file mode 100644 index 0000000..0fe0974 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/ParticipantService.java @@ -0,0 +1,220 @@ +package ru.mskobaro.tdms.business.service; + +import io.micrometer.common.util.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.mskobaro.tdms.business.entity.*; +import ru.mskobaro.tdms.business.exception.AccessDeniedException; +import ru.mskobaro.tdms.business.exception.BusinessException; +import ru.mskobaro.tdms.integration.database.*; +import ru.mskobaro.tdms.presentation.controller.payload.ParticipantSaveDTO; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@Slf4j +public class ParticipantService { + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private UserService userService; + @Autowired + private RoleService roleService; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private UserRepository userRepository; + @Autowired + private GroupRepository groupRepository; + @Autowired + private StudentDataRepository studentDataRepository; + @Autowired + private AuthenticationService authenticationService; + @Autowired + private TeacherDataRepository teacherDataRepository; + + public ParticipantService(ParticipantRepository participantRepository) { + this.participantRepository = participantRepository; + } + + public Collection getAllParticipants() { + return participantRepository.findAll(); + } + + public void saveParticipant(ParticipantSaveDTO participantSaveDTO) { + boolean editMode = participantSaveDTO.getId() != null; + log.info("Saving participant: {}. Edit: {}", participantSaveDTO, editMode); + Participant existingParticipant = null; + if (editMode) + existingParticipant = participantRepository.findByIdThrow(participantSaveDTO.getId()); + persistParticipant(participantSaveDTO, existingParticipant, editMode); + } + + private void persistParticipant(ParticipantSaveDTO participantSaveDTO, Participant existingParticipant, boolean editMode) { + Participant participant = !editMode ? new Participant() : existingParticipant; + User callerUser = userService.getCallerUser(); + if (callerUser == null) + throw new AccessDeniedException(); + + participant.setFirstName(participantSaveDTO.getFirstName()); + participant.setLastName(participantSaveDTO.getLastName()); + participant.setMiddleName(participantSaveDTO.getMiddleName()); + participant.setNumberPhone(participantSaveDTO.getNumberPhone()); + participant.setEmail(participantSaveDTO.getEmail()); + + List roles = persistRoles(participantSaveDTO, existingParticipant, editMode, callerUser, participant); + boolean credentialsChanged = persistUserData(participantSaveDTO, existingParticipant, editMode, participant); + persistStudentData(participantSaveDTO, existingParticipant, editMode, roles, participant); + persistTeacherData(participantSaveDTO, existingParticipant, editMode, roles, participant); + + // TODO: notification task + Participant saved = participantRepository.save(participant); + log.info("Participant saved: {}", saved.getFullName()); + + if (credentialsChanged) { + log.info("User {} changed credentials, logging out", saved.getUser().getUsername()); + authenticationService.logout(saved.getUser().getUsername()); + } + } + + private List persistRoles(ParticipantSaveDTO participantSaveDTO, Participant existingParticipant, boolean editMode, User callerUser, Participant participant) { + boolean isAdmin = roleService.isParticInAuthority(callerUser.getParticipant(), RoleService.Authority.ADMIN); + boolean isSecretary = roleService.isParticInAuthority(callerUser.getParticipant(), RoleService.Authority.SECRETARY); + boolean isOwner = existingParticipant != null && existingParticipant.getUser() != null + && existingParticipant.getUser().getId().equals(callerUser.getId()); + if (!isAdmin && !isSecretary && !isOwner) + throw new AccessDeniedException(); + if (participantSaveDTO.getAuthorities() != null && participantSaveDTO.getAuthorities().contains(RoleService.Authority.ADMIN) && !isAdmin) + throw new AccessDeniedException("Недостаточно прав для назначения роли администратора"); + + List roles = participantSaveDTO.getAuthorities() != null + ? participantSaveDTO.getAuthorities() + .stream() + .map(roleService::getRoleByAuthority) + .collect(Collectors.toList()) + : null; + + if (editMode && isOwner && !isAdmin && roles != null && !ListUtils.isEqualList(roles, existingParticipant.getRoles())) { + throw new AccessDeniedException("Вы не можете изменять свои роли"); + } else if (roles != null) { + participant.setRoles(roles); + } + return roles; + } + + private boolean persistUserData(ParticipantSaveDTO participantSaveDTO, Participant existingParticipant, boolean editMode, Participant participant) { + boolean credentialsChanged = false; + if (participantSaveDTO.getUserData() == null) { + return credentialsChanged; + } + + User user; + boolean wasBefore = false; + if (editMode && existingParticipant.getUser() != null) { + user = existingParticipant.getUser(); + wasBefore = true; + } else { + user = new User(); + } + + String login = participantSaveDTO.getUserData().getLogin(); + if (StringUtils.isNotBlank(login)) { + user.setLogin(login); + credentialsChanged = true; + } else if (!wasBefore) { + throw new BusinessException("Логин не может быть пустым"); + } + + String password = participantSaveDTO.getUserData().getPassword(); + if (StringUtils.isNotBlank(password)) { + user.setPassword(passwordEncoder.encode(password)); + credentialsChanged = true; + } else if (!wasBefore) { + throw new BusinessException("Пароль не может быть пустым"); + } + + user = userRepository.save(user); + participant.setUser(user); + user.setParticipant(participant); + return credentialsChanged; + } + + private void persistTeacherData(ParticipantSaveDTO participantSaveDTO, Participant existingParticipant, boolean editMode, List roles, Participant participant) { + boolean shouldPersistTeacherData = participantSaveDTO.getTeacherData() != null && roles != null + && CollectionUtils.containsAny(roles, roleService.getRoleByAuthority(RoleService.Authority.TEACHER)); + if (!shouldPersistTeacherData) { + return; + } + + boolean alreadyExists = editMode && teacherDataRepository.existsByParticipant_IdAndParticipant_DeletedFalse(existingParticipant.getId()); + TeacherData teacherData; + if (alreadyExists) { + teacherData = teacherDataRepository.findByParticipant_Id(existingParticipant.getId()); + } else { + teacherData = new TeacherData(); + } + + teacherData.setDegree(participantSaveDTO.getTeacherData().getDegree()); + + teacherData = teacherDataRepository.save(teacherData); + teacherData.setParticipant(participant); + } + + private void persistStudentData(ParticipantSaveDTO participantSaveDTO, Participant existingParticipant, boolean editMode, List roles, Participant participant) { + boolean shouldPersistStudentData = participantSaveDTO.getStudentData() != null && roles != null + && CollectionUtils.containsAny(roles, roleService.getRoleByAuthority(RoleService.Authority.STUDENT)); + if (!shouldPersistStudentData) { + return; + } + + boolean alreadyExists = editMode && studentDataRepository.existsByParticipant_IdAndParticipant_DeletedFalse(existingParticipant.getId()); + StudentData studentData; + if (alreadyExists) { + studentData = studentDataRepository.findStudentDataByParticipant_Id(existingParticipant.getId()); + } else { + studentData = new StudentData(); + } + + if (participantSaveDTO.getStudentData().getGroupId() != null) { + Group group = groupRepository.findByIdThrow(participantSaveDTO.getStudentData().getGroupId()); + studentData.setGroup(group); + if (!group.getStudents().contains(studentData)) + group.getStudents().add(studentData); + } else { + if (editMode) { + Group group = groupRepository.findByStudentsContaining(Collections.singletonList(studentData)); + if (group != null) + group.getStudents().remove(studentData); + } + studentData.setGroup(null); + } + + if (participantSaveDTO.getStudentData().getCuratorId() != null) { + TeacherData teacherData = teacherDataRepository.findByIdThrow(participantSaveDTO.getStudentData().getCuratorId()); + studentData.setCurator(teacherData); + } else { + studentData.setCurator(null); + } + + studentData = studentDataRepository.save(studentData); + studentData.setParticipant(participant); + } + + public void deleteParticipant(Long id) { + if (id == 1) { + throw new AccessDeniedException(); + } + + Participant partic = participantRepository.findByIdThrow(id); + partic.setDeleted(true); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/PreparationDirectionService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/PreparationDirectionService.java new file mode 100644 index 0000000..16437b3 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/PreparationDirectionService.java @@ -0,0 +1,34 @@ +package ru.mskobaro.tdms.business.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; +import ru.mskobaro.tdms.integration.database.PreparationDirectionRepository; +import ru.mskobaro.tdms.presentation.controller.payload.PreparationDirectionDTO; + +import java.util.List; + +@Service +@Transactional +public class PreparationDirectionService { + @Autowired + private PreparationDirectionRepository preparationDirectionRepository; + + public List getAll() { + return preparationDirectionRepository.findAll(); + } + + public void save(PreparationDirectionDTO preparationDirectionDTO) { + boolean editMode = preparationDirectionDTO.getId() != null; + DirectionOfPreparation preparationDirection; + if (editMode) { + preparationDirection = preparationDirectionRepository.findByIdThrow(preparationDirectionDTO.getId()); + } else { + preparationDirection = new DirectionOfPreparation(); + } + preparationDirection.setName(preparationDirectionDTO.getName()); + preparationDirection.setCode(preparationDirectionDTO.getCode()); + preparationDirectionRepository.save(preparationDirection); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/RoleService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/RoleService.java similarity index 56% rename from server/src/main/java/ru/mskobaro/tdms/domain/service/RoleService.java rename to server/src/main/java/ru/mskobaro/tdms/business/service/RoleService.java index 7790d3d..a667413 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/RoleService.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/RoleService.java @@ -1,5 +1,7 @@ -package ru.mskobaro.tdms.domain.service; +package ru.mskobaro.tdms.business.service; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -7,7 +9,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ru.mskobaro.tdms.domain.entity.Role; +import ru.mskobaro.tdms.business.entity.Participant; +import ru.mskobaro.tdms.business.entity.Role; import ru.mskobaro.tdms.integration.database.RoleRepository; import java.util.Map; @@ -17,17 +20,23 @@ import java.util.concurrent.ConcurrentHashMap; @Slf4j public class RoleService { @RequiredArgsConstructor - @Getter public enum Authority { ADMIN("ROLE_ADMINISTRATOR"), COMM_MEMBER("ROLE_COMMISSION_MEMBER"), TEACHER("ROLE_TEACHER"), + PLAGIARISM_CHECKER("ROLE_PLAGIARISM_CHECKER"), SECRETARY("ROLE_SECRETARY"), STUDENT("ROLE_STUDENT"), ; private final String authority; + @JsonValue + public String getAuthority() { + return authority; + } + + @JsonCreator public static Authority from(String authority) { for (Authority value : values()) { if (value.getAuthority().equals(authority)) { @@ -37,7 +46,8 @@ public class RoleService { throw new IllegalArgumentException("No such authority: " + authority); } } - public transient Map roles; + + private final Map roles = new ConcurrentHashMap<>(); @Autowired private RoleRepository roleRepository; @@ -45,7 +55,6 @@ public class RoleService { @PostConstruct @Transactional public void bootstrapRolesCache() { - roles = new ConcurrentHashMap<>(); roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role)); log.info("Roles initialized: {}", roles); } @@ -53,4 +62,21 @@ public class RoleService { public Role getRoleByAuthority(Authority authority) { return roles.get(authority.getAuthority()); } + + public boolean isParticInAuthority(Participant participant, Authority authority) { + return participant.getRoles().stream() + .anyMatch(role -> role.getAuthority().equals(authority.getAuthority())); + } + + public boolean isParticInAnyAuthority(Participant participant, Authority... authority) { + return participant.getRoles().stream() + .anyMatch(role -> { + for (Authority auth : authority) { + if (role.getAuthority().equals(auth.getAuthority())) { + return true; + } + } + return false; + }); + } } diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/StudentDataService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/StudentDataService.java new file mode 100644 index 0000000..a10ff24 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/StudentDataService.java @@ -0,0 +1,29 @@ +package ru.mskobaro.tdms.business.service; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.mskobaro.tdms.business.entity.Participant; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.integration.database.StudentDataRepository; +import ru.mskobaro.tdms.integration.database.UserRepository; + +import java.util.Collection; +import java.util.List; + +@Service +@Transactional +@Slf4j +public class StudentDataService { + @Autowired + private StudentDataRepository studentDataRepository; + + public StudentData getStudentByParticIdThrow(Long particId) { + return studentDataRepository.findStudentDataByParticipant_Id(particId); + } + + public Collection getAllStudentsWithoutGroup() { + return studentDataRepository.findByGroupIsNull(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/SysInfoService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/SysInfoService.java similarity index 86% rename from server/src/main/java/ru/mskobaro/tdms/domain/service/SysInfoService.java rename to server/src/main/java/ru/mskobaro/tdms/business/service/SysInfoService.java index 6e31cf6..38a068e 100644 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/SysInfoService.java +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/SysInfoService.java @@ -1,4 +1,4 @@ -package ru.mskobaro.tdms.domain.service; +package ru.mskobaro.tdms.business.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/TaskService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/TaskService.java new file mode 100644 index 0000000..ba9e370 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/TaskService.java @@ -0,0 +1,63 @@ +package ru.mskobaro.tdms.business.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.entity.Task; +import ru.mskobaro.tdms.business.entity.User; +import ru.mskobaro.tdms.business.taskfields.DiplomaTopicAgreementTaskFields; +import ru.mskobaro.tdms.business.taskfields.TaskFields; +import ru.mskobaro.tdms.integration.database.StudentDataRepository; +import ru.mskobaro.tdms.integration.database.TaskRepository; +import ru.mskobaro.tdms.presentation.controller.TaskController; + +import java.util.List; + +@Service +@Transactional +public class TaskService { + @Autowired + private TaskRepository taskRepository; + @Autowired + private UserService userService; + @Autowired + private StudentDataRepository studentDataRepository; + + public void createTask(Task.Type type, Task.Status status, TaskFields taskFields) { + Task task = new Task(); + task.setType(type); + task.setStatus(status); + task.setFields(taskFields); + taskRepository.save(task); + } + + public Task findDiplomaTopicAgreementTaskCallerMaker() { + User user = userService.getCallerUser(); + List diplomaTopicAgreementTaskByMakerId = taskRepository.findDiplomaTopicAgreementTaskByMakerId( + user.getParticipant().getId(), Task.Type.DIPLOMA_TOPIC_AGREEMENT + ); + if (diplomaTopicAgreementTaskByMakerId.isEmpty()) { + return null; + } + + if (diplomaTopicAgreementTaskByMakerId.size() > 1) { + throw new IllegalStateException(); + } + + return diplomaTopicAgreementTaskByMakerId; + } + + public void createDiplomaAgreementTask(TaskController.DiplomaTopicAgreementDTO diplomaTopicAgreementDTO) { + DiplomaTopicAgreementTaskFields taskFields = new DiplomaTopicAgreementTaskFields(); + User user = userService.getCallerUser(); + StudentData studentData = studentDataRepository.findStudentDataByParticipant_Id(user.getParticipant().getId()); + + taskFields.setCheckerParticipantId(user.getParticipant().getId()); + taskFields.setDiplomaTopicId(diplomaTopicAgreementDTO.getDiplomaTopicId()); + taskFields.setDiplomaTopicName(diplomaTopicAgreementDTO.getDiplomaTopicName()); + taskFields.setCheckerParticipantId(studentData.getCurator().getId()); + + createTask(Task.Type.DIPLOMA_TOPIC_AGREEMENT, Task.Status.WAIT_FOR_TOPIC_AGREEMENT, taskFields); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/TeacherDataService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/TeacherDataService.java new file mode 100644 index 0000000..f873a85 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/TeacherDataService.java @@ -0,0 +1,30 @@ +package ru.mskobaro.tdms.business.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.mskobaro.tdms.business.entity.TeacherData; +import ru.mskobaro.tdms.business.exception.NotFoundException; +import ru.mskobaro.tdms.integration.database.TeacherDataRepository; + +import java.util.List; + +@Service +@Transactional +public class TeacherDataService { + @Autowired + private TeacherDataRepository teacherDataRepository; + + public List findAll() { + return teacherDataRepository.findAll(); + } + + public TeacherData getTeacherDataByParticipantId(Long participantId) { + TeacherData teacher = teacherDataRepository.findByParticipant_Id(participantId); + if (teacher == null) { + throw new NotFoundException(TeacherData.class, participantId); + } + + return teacher; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/service/UserService.java b/server/src/main/java/ru/mskobaro/tdms/business/service/UserService.java new file mode 100644 index 0000000..51fbd6d --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/service/UserService.java @@ -0,0 +1,50 @@ +package ru.mskobaro.tdms.business.service; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import ru.mskobaro.tdms.business.entity.User; +import ru.mskobaro.tdms.integration.database.UserRepository; +import ru.mskobaro.tdms.presentation.controller.payload.UserDTO; + +import java.util.List; + +@Service +@Transactional +@Slf4j +public class UserService implements UserDetailsService { + @Autowired + private UserRepository userRepository; + + @Override + public User loadUserByUsername(String username) throws UsernameNotFoundException { + log.debug("Loading user with username: {}", username); + User user = userRepository.findUserByLogin(username).orElseThrow( + () -> new UsernameNotFoundException("User with login " + username + " not found")); + log.debug("User with login {} loaded", username); + return user; + } + + public List getAllUsers() { + log.debug("Loading all users"); + List users = userRepository.findAll().stream() + .map(UserDTO::fromEntity) + .toList(); + log.info("{} users loaded", users.size()); + return users; + } + + public User getCallerUser() { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof User)) { + return null; + } + + return (User) principal; + } +} + diff --git a/server/src/main/java/ru/mskobaro/tdms/business/taskfields/DiplomaTopicAgreementTaskFields.java b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/DiplomaTopicAgreementTaskFields.java new file mode 100644 index 0000000..07fa3c3 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/DiplomaTopicAgreementTaskFields.java @@ -0,0 +1,11 @@ +package ru.mskobaro.tdms.business.taskfields; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DiplomaTopicAgreementTaskFields extends MakerCheckerTaskFields { + private String diplomaTopicName; + private Long diplomaTopicId; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerCheckerTaskFields.java b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerCheckerTaskFields.java new file mode 100644 index 0000000..dce60eb --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerCheckerTaskFields.java @@ -0,0 +1,13 @@ +package ru.mskobaro.tdms.business.taskfields; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class MakerCheckerTaskFields extends MakerTaskFields { + private Long checkerParticipantId; + private LocalDateTime approvedAt; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerTaskFields.java b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerTaskFields.java new file mode 100644 index 0000000..c27b1ac --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/MakerTaskFields.java @@ -0,0 +1,10 @@ +package ru.mskobaro.tdms.business.taskfields; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class MakerTaskFields extends TaskFields { + private Long makerParticipantId; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/business/taskfields/TaskFields.java b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/TaskFields.java new file mode 100644 index 0000000..20b8a27 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/business/taskfields/TaskFields.java @@ -0,0 +1,10 @@ +package ru.mskobaro.tdms.business.taskfields; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class TaskFields { + private LocalDateTime createdAt; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Group.java b/server/src/main/java/ru/mskobaro/tdms/domain/entity/Group.java deleted file mode 100644 index 72547ff..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Group.java +++ /dev/null @@ -1,35 +0,0 @@ -package ru.mskobaro.tdms.domain.entity; - - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.ZonedDateTime; - - -@Getter -@Setter -@ToString -@Entity -@Table(name = "`group`") -public class Group { - @Id - @Column(name = "id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(name = "name") - private String name; - @ManyToOne - @JoinColumn(name = "curator_teacher_id") - private Teacher groupCurator; - @Column(name = "created_at") - @CreationTimestamp - private ZonedDateTime createdAt; - @Column(name = "updated_at") - @UpdateTimestamp - private ZonedDateTime updatedAt; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Student.java b/server/src/main/java/ru/mskobaro/tdms/domain/entity/Student.java deleted file mode 100644 index 230230d..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Student.java +++ /dev/null @@ -1,68 +0,0 @@ -package ru.mskobaro.tdms.domain.entity; - - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.ZonedDateTime; - - -@Getter -@Setter -@ToString -@Entity -@Table(name = "student") -public class Student { - @Id - @Column(name = "id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(name = "form") - private Boolean form; - @Column(name = "protection_order") - private Integer protectionOrder; - @Column(name = "magistracy") - private String magistracy; - @Column(name = "digital_format_present") - private Boolean digitalFormatPresent; - @Column(name = "mark_comment") - private Integer markComment; - @Column(name = "mark_practice") - private Integer markPractice; - @Column(name = "predefence_comment") - private String predefenceComment; - @Column(name = "normal_control") - private String normalControl; - @Column(name = "anti_plagiarism") - private Integer antiPlagiarism; - @Column(name = "note") - private String note; - @Column(name = "record_book_returned") - private Boolean recordBookReturned; - @Column(name = "work") - private String work; - @OneToOne - @JoinColumn(name = "user_id") - private User user; - @ManyToOne - @JoinColumn(name = "diploma_topic_id") - private DiplomaTopic diplomaTopic; - // Научный руководитель - @ManyToOne - @JoinColumn(name = "adviser_teacher_id") - private Teacher adviser; - @ManyToOne - @JoinColumn(name = "group_id") - private Group group; - - @Column(name = "created_at") - @CreationTimestamp - private ZonedDateTime createdAt; - @Column(name = "updated_at") - @UpdateTimestamp - private ZonedDateTime updatedAt; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Teacher.java b/server/src/main/java/ru/mskobaro/tdms/domain/entity/Teacher.java deleted file mode 100644 index 4bd1938..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/entity/Teacher.java +++ /dev/null @@ -1,36 +0,0 @@ -package ru.mskobaro.tdms.domain.entity; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.ZonedDateTime; -import java.util.List; - -@Getter -@Setter -@ToString -@Entity -@Table(name = "teacher") -public class Teacher { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @OneToOne - @JoinColumn(name = "user_id") - private User user; - @OneToMany(mappedBy = "groupCurator") - private List curatingGroups; - @OneToMany(mappedBy = "adviser") - private List advisingStudents; - - @Column(name = "created_at") - @CreationTimestamp - private ZonedDateTime createdAt; - @Column(name = "updated_at") - @UpdateTimestamp - private ZonedDateTime updatedAt; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/DiplomaTopicService.java b/server/src/main/java/ru/mskobaro/tdms/domain/service/DiplomaTopicService.java deleted file mode 100644 index adccdae..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/DiplomaTopicService.java +++ /dev/null @@ -1,9 +0,0 @@ -package ru.mskobaro.tdms.domain.service; - -import jakarta.transaction.Transactional; -import org.springframework.stereotype.Service; - -@Service -@Transactional -public class DiplomaTopicService { -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/GroupService.java b/server/src/main/java/ru/mskobaro/tdms/domain/service/GroupService.java deleted file mode 100644 index f67babe..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/GroupService.java +++ /dev/null @@ -1,67 +0,0 @@ -package ru.mskobaro.tdms.domain.service; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import ru.mskobaro.tdms.domain.entity.Group; -import ru.mskobaro.tdms.domain.entity.User; -import ru.mskobaro.tdms.domain.exception.BusinessException; -import ru.mskobaro.tdms.integration.database.GroupRepository; -import ru.mskobaro.tdms.presentation.payload.GroupDTO; -import ru.mskobaro.tdms.presentation.payload.GroupEditDTO; - -import java.util.Collection; -import java.util.List; - -@Service -@Transactional -@Slf4j -public class GroupService { - @Autowired - private GroupRepository groupRepository; - @Autowired - private UserService userService; - - public Collection getAllGroups() { - log.info("Getting all groups"); - List groups = groupRepository.findAll(); - User callerUser = userService.getCallerUser(); - - List result = groups.stream().map(group -> { - GroupDTO groupDTO = new GroupDTO(); - groupDTO.setName(group.getName()); - groupDTO.setId(group.getId()); - - if (group.getGroupCurator() != null) { - groupDTO.setCuratorName(group.getGroupCurator().getUser().getFullName()); - if (callerUser != null) { - groupDTO.setIAmCurator(group.getGroupCurator().getUser().equals(callerUser)); - } - } - return groupDTO; - }).toList(); - - log.info("Found {} groups", result.size()); - return result; - } - - public void createGroup(String groupName) { - log.info("Creating group with name {}", groupName); - if (groupRepository.existsByName(groupName)) { - throw new BusinessException("Группа с именем " + groupName + " уже существует"); - } - - Group group = new Group(); - group.setName(groupName); - Group saved = groupRepository.save(group); - log.info("Group saved: {}", saved); - } - - public void editGroup(GroupEditDTO groupEditDTO) { - log.info("Updating group with dto: {}", groupEditDTO); - Group group = groupRepository.findByIdThrow(groupEditDTO.getId()); - group.setName(groupEditDTO.getName()); - log.info("Group updated: {}", group); - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/StudentService.java b/server/src/main/java/ru/mskobaro/tdms/domain/service/StudentService.java deleted file mode 100644 index f64f825..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/StudentService.java +++ /dev/null @@ -1,57 +0,0 @@ -package ru.mskobaro.tdms.domain.service; - -import jakarta.transaction.Transactional; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import ru.mskobaro.tdms.domain.entity.Student; -import ru.mskobaro.tdms.domain.entity.User; -import ru.mskobaro.tdms.domain.exception.BusinessException; -import ru.mskobaro.tdms.domain.exception.NotFoundException; -import ru.mskobaro.tdms.integration.database.StudentRepository; -import ru.mskobaro.tdms.integration.database.UserRepository; -import ru.mskobaro.tdms.presentation.payload.StudentDTO; - -@Service -@Transactional -@Slf4j -public class StudentService { - @Autowired - private StudentRepository studentRepository; - @Autowired - private UserService userService; - @Autowired - private UserRepository userRepository; - - public Student getCallerStudentThrow() { - userService.sureCallerInAnyRole(RoleService.Authority.STUDENT); - return studentRepository.findByUser(userService.getCallerUser()); - } - - public StudentDTO getCallerStudentDtoThrow() { - Student callerStudent = getCallerStudentThrow(); - if (callerStudent == null) { - throw new BusinessException("Вызывающий пользователь является студентом, но ассоциированный с ним студент не найден"); - } - - return StudentDTO.from(callerStudent); - } - - public StudentDTO getStudentByUserIdThrow(Long id) { - User callerUser = userService.getCallerUser(); - if (callerUser == null) { - throw new NotFoundException(); - } - - if (callerUser.getId().equals(id)) { - return getCallerStudentDtoThrow(); - } else if (userService.isCallerInRole(RoleService.Authority.ADMIN, RoleService.Authority.TEACHER, RoleService.Authority.SECRETARY)) { - User user = userRepository.findByIdThrow(id); - Student student = studentRepository.findByUser(user); - NotFoundException.throwIfNull(student, Student.class, user.getId()); - return StudentDTO.from(student); - } else { - throw new NotFoundException(Student.class, id); - } - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/domain/service/UserService.java b/server/src/main/java/ru/mskobaro/tdms/domain/service/UserService.java deleted file mode 100644 index bf6eec2..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/domain/service/UserService.java +++ /dev/null @@ -1,181 +0,0 @@ -package ru.mskobaro.tdms.domain.service; - -import jakarta.transaction.Transactional; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import ru.mskobaro.tdms.domain.entity.*; -import ru.mskobaro.tdms.domain.exception.AccessDeniedException; -import ru.mskobaro.tdms.domain.exception.NotFoundException; -import ru.mskobaro.tdms.integration.database.GroupRepository; -import ru.mskobaro.tdms.integration.database.StudentRepository; -import ru.mskobaro.tdms.integration.database.TeacherRepository; -import ru.mskobaro.tdms.integration.database.UserRepository; -import ru.mskobaro.tdms.presentation.payload.RegistrationDTO; -import ru.mskobaro.tdms.presentation.payload.UserDTO; - -import java.util.List; -import java.util.Optional; - -@Service -@Transactional -@Slf4j -public class UserService implements UserDetailsService { - @Autowired - private UserRepository userRepository; - @Autowired - private GroupRepository groupRepository; - @Autowired - private StudentRepository studentRepository; - @Autowired - private RoleService roleService; - @Autowired - private PasswordEncoder passwordEncoder; - @Autowired - private TeacherRepository teacherRepository; - - @Override - public User loadUserByUsername(String username) throws UsernameNotFoundException { - log.debug("Loading user with username: {}", username); - User user = userRepository.findUserByLogin(username).orElseThrow( - () -> new UsernameNotFoundException("User with login " + username + " not found")); - log.debug("User with login {} loaded", username); - return user; - } - - public List getAllUsers() { - log.debug("Loading all users"); - List users = userRepository.findAll().stream() - .map(UserDTO::from) - .toList(); - log.info("{} users loaded", users.size()); - return users; - } - - public void registerUser(RegistrationDTO registrationDTO) { - log.info("Registering user: {}", registrationDTO); - - User user = transientUser(registrationDTO); - fillRoles(user, registrationDTO); - userRepository.save(user); - - if (userInAnyRole(user, RoleService.Authority.STUDENT)) { - sureCallerInAnyRole(RoleService.Authority.ADMIN, RoleService.Authority.SECRETARY); - Student student = transientStudent(registrationDTO.getStudentData()); - student.setUser(user); - student = studentRepository.save(student); - log.info("New user is student: {}", student); - } else if (userInAnyRole(user, RoleService.Authority.TEACHER)) { - sureCallerInAnyRole(RoleService.Authority.ADMIN, RoleService.Authority.SECRETARY); - Teacher teacher = transientTeacher(registrationDTO.getTeacherData()); - teacher.setUser(user); - teacher = teacherRepository.save(teacher); - log.info("New user is teacher: {}", teacher); - } else if (userInAnyRole(user, RoleService.Authority.ADMIN)) { - sureCallerInAnyRole(RoleService.Authority.ADMIN); - log.info("New user is administrator"); - } else { - throw new UnsupportedOperationException("Role not supported: " + user.getAuthorities()); - } - } - - private Teacher transientTeacher(RegistrationDTO.TeacherRegistrationDTO teacherData) { - if (teacherData == null) - throw new NullPointerException("Teacher data is null"); - if (teacherData.getCuratingGroups() == null) - teacherData.setCuratingGroups(List.of()); - if (teacherData.getAdvisingStudents() == null) - teacherData.setAdvisingStudents(List.of()); - - Teacher teacher = new Teacher(); - - List groups = groupRepository.findAllById(teacherData.getCuratingGroups()); - if (groups.size() != teacherData.getCuratingGroups().size()) { - throw new NotFoundException(Teacher.class); - } - List students = studentRepository.findAllById(teacherData.getAdvisingStudents()); - if (students.size() != teacherData.getAdvisingStudents().size()) { - throw new NotFoundException(Student.class); - } - - teacher.setCuratingGroups(groups); - teacher.setAdvisingStudents(students); - return teacher; - } - - private User transientUser(RegistrationDTO registrationDTO) { - User user = new User(); - user.setLogin(registrationDTO.getLogin()); - user.setPassword(passwordEncoder.encode(registrationDTO.getPassword())); - user.setFullName(registrationDTO.getFullName()); - user.setEmail(registrationDTO.getEmail()); - user.setNumberPhone(registrationDTO.getNumberPhone()); - return user; - } - - private Student transientStudent(RegistrationDTO.StudentRegistrationDTO studentData) { - if (studentData == null) - throw new NullPointerException("Student data is null"); - - Student student = new Student(); - if (studentData.getGroupId() != null) { - student.setGroup(groupRepository.findByIdThrow(studentData.getGroupId())); - } - - return student; - } - - private void fillRoles(User user, RegistrationDTO registrationDTO) { - RoleService.Authority accountType = registrationDTO.getAccountType(); - Role role = roleService.getRoleByAuthority(accountType); - if (role == null) { - throw new IllegalArgumentException("Role not found for authority: " + accountType); - } - user.setRoles(List.of(role)); - } - - public boolean userInAnyRole(User user, RoleService.Authority... authority) { - if (user == null || authority == null) { - return false; - } - - List toCheckAuthorities = List.of(authority); - return user.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .map(RoleService.Authority::from) - .anyMatch(toCheckAuthorities::contains); - } - - public User getCallerUser() { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (!(principal instanceof User)) { - return null; - } - - return (User) principal; - } - - public Optional getCallerOptional() { - return Optional.ofNullable(getCallerUser()); - } - - public UserDTO getCallerUserDTO() { - return getCallerOptional().map(UserDTO::from).orElse(UserDTO.unauthenticated()); - } - - public boolean isCallerInRole(RoleService.Authority... authorities) { - return userInAnyRole(getCallerUser(), authorities); - } - - public void sureCallerInAnyRole(RoleService.Authority... authority) { - if (!isCallerInRole(authority)) { - throw new AccessDeniedException(); - } - } -} - diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/DefenceRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/DefenceRepository.java index b7348bb..5736206 100644 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/DefenceRepository.java +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/DefenceRepository.java @@ -1,4 +1,25 @@ package ru.mskobaro.tdms.integration.database; -public interface DefenceRepository { +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.Defense; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.Optional; + +@Repository +public interface DefenceRepository extends JpaRepository { + default Defense findByIdThrow(Long id) { + return this.findById(id).orElseThrow(() -> new NotFoundException(Defense.class, id)); + } + + @Override + @Query("SELECT d from Defense d " + + "left join fetch d.bestWorks bw " + + "left join fetch d.commissionMembers cm " + + "where d.id = :id " + + "and bw.participant.deleted = false " + + "and cm.participant.deleted = false") + Optional findById(Long id); } diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/DiplomaTopicRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/DiplomaTopicRepository.java index fcfa333..c9a6f20 100644 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/DiplomaTopicRepository.java +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/DiplomaTopicRepository.java @@ -1,13 +1,29 @@ package ru.mskobaro.tdms.integration.database; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.DiplomaTopic; -import ru.mskobaro.tdms.domain.exception.NotFoundException; +import ru.mskobaro.tdms.business.entity.DiplomaTopic; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; @Repository -public interface DiplomaTopicRepository extends JpaRepository { - default DiplomaTopic findByIdThrow(Integer id) { +public interface DiplomaTopicRepository extends JpaRepository { + default DiplomaTopic findByIdThrow(Long id) { return this.findById(id).orElseThrow(() -> new NotFoundException(DiplomaTopic.class, id)); } + + @Override + @Query("SELECT d FROM DiplomaTopic d WHERE d.id = :id AND d.teacher.participant.deleted = false") + Optional findById(Long id); + + @Query("SELECT d FROM DiplomaTopic d " + + "inner join d.directionOfPreparation dp " + + "inner join StudentData sd on sd.id = :studentIdwhere " + + "where dp = sd.group.directionOfPreparation") + List findAllForStudentId(Long studentId); } \ No newline at end of file diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/GroupRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/GroupRepository.java index bc6d4b7..6421637 100644 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/GroupRepository.java +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/GroupRepository.java @@ -1,9 +1,14 @@ package ru.mskobaro.tdms.integration.database; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.Group; -import ru.mskobaro.tdms.domain.exception.NotFoundException; +import ru.mskobaro.tdms.business.entity.Group; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; @Repository public interface GroupRepository extends JpaRepository { @@ -11,5 +16,12 @@ public interface GroupRepository extends JpaRepository { return this.findById(id).orElseThrow(() -> new NotFoundException(Group.class, id)); } + @Override + @Query("SELECT g FROM Group g left join fetch g.students sd WHERE g.id = :id") + Optional findById(Long id); + boolean existsByName(String name); + + @Query("SELECT g FROM Group g left join fetch g.students sd WHERE sd IN :students") + Group findByStudentsContaining(List students); } diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/ParticipantRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/ParticipantRepository.java new file mode 100644 index 0000000..3fde395 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/ParticipantRepository.java @@ -0,0 +1,27 @@ +package ru.mskobaro.tdms.integration.database; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.DiplomaTopic; +import ru.mskobaro.tdms.business.entity.Participant; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ParticipantRepository extends JpaRepository { + + default Participant findByIdThrow(Long id) { + return this.findById(id).orElseThrow(() -> new NotFoundException(DiplomaTopic.class, id)); + } + + @Override + @Query("SELECT p FROM Participant p WHERE p.id = :id AND p.deleted = false") + Optional findById(Long id); + + @Override + @Query("SELECT p from Participant p where p.deleted = false") + List findAll(); +} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/PreparationDirectionRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/PreparationDirectionRepository.java new file mode 100644 index 0000000..98bc83e --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/PreparationDirectionRepository.java @@ -0,0 +1,13 @@ +package ru.mskobaro.tdms.integration.database; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +@Repository +public interface PreparationDirectionRepository extends JpaRepository { + default DirectionOfPreparation findByIdThrow(Long id) { + return this.findById(id).orElseThrow(() -> new NotFoundException(DirectionOfPreparation.class, id)); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/RoleRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/RoleRepository.java index 1020358..5950a89 100644 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/RoleRepository.java +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/RoleRepository.java @@ -2,7 +2,7 @@ package ru.mskobaro.tdms.integration.database; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.Role; +import ru.mskobaro.tdms.business.entity.Role; @Repository public interface RoleRepository extends JpaRepository { diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentDataRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentDataRepository.java new file mode 100644 index 0000000..671fe19 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentDataRepository.java @@ -0,0 +1,39 @@ +package ru.mskobaro.tdms.integration.database; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StudentDataRepository extends JpaRepository { + default StudentData findByIdThrow(Long id) { + return this.findById(id).orElseThrow(() -> new NotFoundException(StudentData.class, id)); + } + + @EntityGraph(type = EntityGraph.EntityGraphType.LOAD, attributePaths = {"group.students"}) + @Query("SELECT s FROM StudentData s join fetch s.participant p WHERE p.id = :id AND p.deleted = false") + StudentData findStudentDataByParticipant_Id(Long id); + + boolean existsByParticipant_IdAndParticipant_DeletedFalse(Long id); + + @Override + @EntityGraph(type = EntityGraph.EntityGraphType.LOAD, attributePaths = {"participant.roles"}) + @Query("SELECT s FROM StudentData s join fetch s.participant p WHERE s.id in :ids AND p.deleted = false") + List findAllById(Iterable ids); + + @Query("SELECT s FROM StudentData s join fetch s.participant p WHERE s.group is null and p.deleted = false") + List findByGroupIsNull(); + + @Query("SELECT s FROM StudentData s join fetch s.participant p join fetch s.group g WHERE g.id = :id AND p.deleted = false") + List findAllByGroup_Id(Long id); + + @Override + @Query("SELECT s FROM StudentData s join fetch s.participant p WHERE s.id = :id AND p.deleted = false") + Optional findById(Long id); +} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentRepository.java deleted file mode 100644 index 03a0cc1..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/StudentRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package ru.mskobaro.tdms.integration.database; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.Student; -import ru.mskobaro.tdms.domain.entity.User; -import ru.mskobaro.tdms.domain.exception.NotFoundException; - -@Repository -public interface StudentRepository extends JpaRepository { - default Student findByIdThrow(Long id) { - return this.findById(id).orElseThrow(() -> new NotFoundException(Student.class, id)); - } - - Student findByUser(User user); -} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/TaskRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/TaskRepository.java new file mode 100644 index 0000000..7d61ab8 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/TaskRepository.java @@ -0,0 +1,21 @@ +package ru.mskobaro.tdms.integration.database; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.Task; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; + +@Repository +public interface TaskRepository extends JpaRepository { + default Task findByIdThrow(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException(Task.class, id)); + } + + @Query(value = "SELECT t FROM task t " + + "WHERE t.type = :type " + + "and t.fields->>'makerParticipantId' = :id", nativeQuery = true) + List findDiplomaTopicAgreementTaskByMakerId(Long id, Task.Type type); +} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherDataRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherDataRepository.java new file mode 100644 index 0000000..d4f415f --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherDataRepository.java @@ -0,0 +1,30 @@ +package ru.mskobaro.tdms.integration.database; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import ru.mskobaro.tdms.business.entity.TeacherData; +import ru.mskobaro.tdms.business.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TeacherDataRepository extends JpaRepository { + default TeacherData findByIdThrow(Long id) { + return this.findById(id).orElseThrow(() -> new NotFoundException(TeacherData.class, id)); + } + + @Override + @Query("SELECT t FROM TeacherData t WHERE t.id = :id AND t.participant.deleted = false") + Optional findById(Long id); + + boolean existsByParticipant_IdAndParticipant_DeletedFalse(Long id); + + @Query("SELECT t FROM TeacherData t WHERE t.participant.id = :id AND t.participant.deleted = false") + TeacherData findByParticipant_Id(Long id); + + @Override + @Query("select t from TeacherData t where t.participant.deleted = false") + List findAll(); +} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherRepository.java deleted file mode 100644 index 27f212d..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/TeacherRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package ru.mskobaro.tdms.integration.database; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.Teacher; -import ru.mskobaro.tdms.domain.exception.NotFoundException; - -@Repository -public interface TeacherRepository extends JpaRepository { - default Teacher findByIdThrow(Long id) { - return this.findById(id).orElseThrow(() -> new NotFoundException(Teacher.class, id)); - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/integration/database/UserRepository.java b/server/src/main/java/ru/mskobaro/tdms/integration/database/UserRepository.java index 17009c8..03f466d 100644 --- a/server/src/main/java/ru/mskobaro/tdms/integration/database/UserRepository.java +++ b/server/src/main/java/ru/mskobaro/tdms/integration/database/UserRepository.java @@ -1,9 +1,10 @@ package ru.mskobaro.tdms.integration.database; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; -import ru.mskobaro.tdms.domain.entity.User; -import ru.mskobaro.tdms.domain.exception.NotFoundException; +import ru.mskobaro.tdms.business.entity.User; +import ru.mskobaro.tdms.business.exception.NotFoundException; import java.util.Optional; @@ -12,5 +13,11 @@ public interface UserRepository extends JpaRepository { default User findByIdThrow(Long id) { return this.findById(id).orElseThrow(() -> new NotFoundException(User.class, id)); } + + @Override + @Query("SELECT u from User u join fetch u.participant p where u.id = :id and p.deleted = false") + Optional findById(Long id); + + @Query("SELECT u from User u join fetch u.participant p where u.login = :login and p.deleted = false") Optional findUserByLogin(String login); } diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DefenceController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DefenceController.java new file mode 100644 index 0000000..0ef7a5c --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DefenceController.java @@ -0,0 +1,22 @@ +package ru.mskobaro.tdms.presentation.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import ru.mskobaro.tdms.business.service.DefenceService; +import ru.mskobaro.tdms.presentation.controller.payload.DefenceDTO; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/defence") +public class DefenceController { + @Autowired + private DefenceService defenceService; + + @GetMapping("/all") + public List getAllDefences() { + return defenceService.getAllDefences(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DiplomaTopicController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DiplomaTopicController.java index efb764a..7294156 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DiplomaTopicController.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/DiplomaTopicController.java @@ -1,13 +1,34 @@ package ru.mskobaro.tdms.presentation.controller; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import ru.mskobaro.tdms.business.service.DiplomaTopicService; +import ru.mskobaro.tdms.presentation.controller.payload.DiplomaTopicDTO; + +import java.util.List; @RestController @RequestMapping("/api/v1/diploma-topic/") @Validated public class DiplomaTopicController { + @Autowired + private DiplomaTopicService diplomaTopicService; + + @GetMapping("/all") + public List getAll() { + return diplomaTopicService.findAll().stream().map(DiplomaTopicDTO::from).toList(); + } + + @PostMapping("/save") + public void save(@RequestBody DiplomaTopicDTO diplomaTopicDTO) { + diplomaTopicService.save(diplomaTopicDTO); + } + + @GetMapping("/all-for-student") + public List getAllForStudent(@RequestParam Long studentId) { + return diplomaTopicService.findAllForStudent(studentId).stream().map(DiplomaTopicDTO::from).toList(); + } } diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/GroupController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/GroupController.java index 74dbb7b..2eeb7e0 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/GroupController.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/GroupController.java @@ -3,10 +3,8 @@ package ru.mskobaro.tdms.presentation.controller; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import ru.mskobaro.tdms.domain.service.GroupService; -import ru.mskobaro.tdms.presentation.payload.GroupCreateDTO; -import ru.mskobaro.tdms.presentation.payload.GroupDTO; -import ru.mskobaro.tdms.presentation.payload.GroupEditDTO; +import ru.mskobaro.tdms.business.service.GroupService; +import ru.mskobaro.tdms.presentation.controller.payload.GroupDTO; import java.util.Collection; @@ -21,13 +19,13 @@ public class GroupController { return groupService.getAllGroups(); } - @PostMapping("/create-group") - public void createGroup(@RequestBody @Valid GroupCreateDTO groupCreateDTO) { - groupService.createGroup(groupCreateDTO.getName()); + @PostMapping("/save") + public void save(@RequestBody @Valid GroupDTO groupDTO) { + groupService.save(groupDTO); } - @PostMapping("/edit-group") - public void editGroup(@RequestBody @Valid GroupEditDTO groupEditDTO) { - groupService.editGroup(groupEditDTO); + @PostMapping("/delete") + public void delete(@RequestParam(value = "id") Long groupId) { + groupService.deleteGroup(groupId); } } diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/ParticipantController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/ParticipantController.java new file mode 100644 index 0000000..7a949c1 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/ParticipantController.java @@ -0,0 +1,35 @@ +package ru.mskobaro.tdms.presentation.controller; + +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import ru.mskobaro.tdms.business.service.ParticipantService; +import ru.mskobaro.tdms.presentation.controller.payload.IdDto; +import ru.mskobaro.tdms.presentation.controller.payload.ParticipantDTO; +import ru.mskobaro.tdms.presentation.controller.payload.ParticipantSaveDTO; + +import java.util.Collection; + +@RestController +@RequestMapping("/api/v1/participant") +public class ParticipantController { + @Autowired + private ParticipantService participantService; + + @GetMapping("/get-all-participants") + public Collection getAllParticipants() { + return participantService.getAllParticipants().stream() + .map(ParticipantDTO::fromEntity) + .toList(); + } + + @PostMapping("/save") + public void registerParticipant(@Valid @RequestBody ParticipantSaveDTO participantSaveDTO) { + participantService.saveParticipant(participantSaveDTO); + } + + @PostMapping("/delete") + public void deleteParticipant(@RequestBody IdDto id) { + participantService.deleteParticipant(id.getId()); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/PreparationDirectionController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/PreparationDirectionController.java new file mode 100644 index 0000000..e2c06be --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/PreparationDirectionController.java @@ -0,0 +1,26 @@ +package ru.mskobaro.tdms.presentation.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import ru.mskobaro.tdms.business.service.PreparationDirectionService; +import ru.mskobaro.tdms.presentation.controller.payload.PreparationDirectionDTO; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v1/prep-direction") +public class PreparationDirectionController { + @Autowired + private PreparationDirectionService preparationDirectionService; + + @GetMapping("/get-all") + public List getAll() { + return preparationDirectionService.getAll().stream().map(PreparationDirectionDTO::from).collect(Collectors.toList()); + } + + @PostMapping("save") + public void save(@RequestBody PreparationDirectionDTO preparationDirectionDTO) { + preparationDirectionService.save(preparationDirectionDTO); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/StudentController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/StudentController.java index fda517c..cd436f2 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/StudentController.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/StudentController.java @@ -5,22 +5,29 @@ 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 ru.mskobaro.tdms.domain.service.StudentService; -import ru.mskobaro.tdms.presentation.payload.StudentDTO; +import ru.mskobaro.tdms.business.entity.StudentData; +import ru.mskobaro.tdms.business.service.StudentDataService; +import ru.mskobaro.tdms.presentation.controller.payload.GroupDTO; +import ru.mskobaro.tdms.presentation.controller.payload.StudentDataDTO; + +import java.util.Collection; @RestController @RequestMapping("/api/v1/student/") public class StudentController { @Autowired - private StudentService studentService; + private StudentDataService studentDataService; - @GetMapping("/current") - public StudentDTO getCurrentStudent() { - return studentService.getCallerStudentDtoThrow(); + @GetMapping(value = "/by-partic-id") + public StudentDataDTO getCurrentStudent(@RequestParam(value = "id") Long particId) { + StudentData studentData = studentDataService.getStudentByParticIdThrow(particId); + return StudentDataDTO.from(studentData, true); } - @GetMapping("/by-user-id") - public StudentDTO getStudentByUserId(@RequestParam Long id) { - return studentService.getStudentByUserIdThrow(id); + @GetMapping(value = "all-without-group") + public Collection getAllStudentsWithoutGroup() { + return studentDataService.getAllStudentsWithoutGroup().stream() + .map(sd -> StudentDataDTO.from(sd, true)) + .toList(); } } diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/SysInfoController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/SysInfoController.java index 9e854fa..eca4bf3 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/SysInfoController.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/SysInfoController.java @@ -4,7 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import ru.mskobaro.tdms.domain.service.SysInfoService; +import ru.mskobaro.tdms.business.service.SysInfoService; @RestController @RequestMapping("/api/v1/sysinfo") diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TaskController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TaskController.java new file mode 100644 index 0000000..8266edf --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TaskController.java @@ -0,0 +1,34 @@ +package ru.mskobaro.tdms.presentation.controller; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import ru.mskobaro.tdms.business.service.TaskService; +import ru.mskobaro.tdms.presentation.controller.payload.TaskDto; + +@RestController +@RequestMapping("/api/v1/task") +public class TaskController { + @Autowired + private TaskService taskService; + + @GetMapping("/diploma-agreement-maker") + public TaskDto diplomaTopicAgreementMaker() { + return TaskDto.from(taskService.findDiplomaTopicAgreementTaskCallerMaker()); + } + + @PostMapping("/create-topic-agreement") + public void createDiplomaTopicAgreement(@RequestBody DiplomaTopicAgreementDTO diplomaTopicAgreementDTO) { + taskService.createDiplomaAgreementTask(diplomaTopicAgreementDTO); + } + + @NoArgsConstructor + @Getter + @Setter + public static class DiplomaTopicAgreementDTO { + private String diplomaTopicName; + private Long diplomaTopicId; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TeacherDataController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TeacherDataController.java new file mode 100644 index 0000000..5e712e3 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/TeacherDataController.java @@ -0,0 +1,28 @@ +package ru.mskobaro.tdms.presentation.controller; + +import org.springframework.beans.factory.annotation.Autowired; +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 ru.mskobaro.tdms.business.service.TeacherDataService; +import ru.mskobaro.tdms.presentation.controller.payload.TeacherDataDTO; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/teacher-data") +public class TeacherDataController { + @Autowired + private TeacherDataService teacherDataService; + + @GetMapping("/get-all") + public List getAllTeacherData() { + return teacherDataService.findAll().stream().map(TeacherDataDTO::from).toList(); + } + + @GetMapping("/by-partic-id") + public TeacherDataDTO getTeacherDataByParticipantId(@RequestParam Long id) { + return TeacherDataDTO.from(teacherDataService.getTeacherDataByParticipantId(id)); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/UserController.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/UserController.java index e32b7c9..b02b0be 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/UserController.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/UserController.java @@ -3,14 +3,14 @@ package ru.mskobaro.tdms.presentation.controller; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import ru.mskobaro.tdms.domain.service.AuthenticationService; -import ru.mskobaro.tdms.domain.service.UserService; -import ru.mskobaro.tdms.presentation.payload.LoginDTO; -import ru.mskobaro.tdms.presentation.payload.RegistrationDTO; -import ru.mskobaro.tdms.presentation.payload.UserDTO; - -import java.util.List; +import ru.mskobaro.tdms.business.entity.User; +import ru.mskobaro.tdms.business.service.AuthenticationService; +import ru.mskobaro.tdms.business.service.UserService; +import ru.mskobaro.tdms.presentation.controller.payload.LoginDTO; +import ru.mskobaro.tdms.presentation.controller.payload.UserDTO; @RestController @RequestMapping("/api/v1/user") @@ -22,8 +22,11 @@ public class UserController { private UserService userService; @GetMapping("/current") - public UserDTO getCurrentUser() { - return userService.getCallerUserDTO(); + public ResponseEntity getCurrentUser() { + User user = userService.getCallerUser(); + return user == null + ? ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() + : ResponseEntity.ok(UserDTO.fromEntity(user)); } @PostMapping("/logout") @@ -35,14 +38,4 @@ public class UserController { public void login(@RequestBody @Valid LoginDTO loginDTO) { authenticationService.login(loginDTO.getLogin(), loginDTO.getPassword()); } - - @PostMapping("/register") - public void register(@RequestBody @Valid RegistrationDTO registrationDTO) { - userService.registerUser(registrationDTO); - } - - @GetMapping("/get-all") - public List getAllUsers() { - return userService.getAllUsers(); - } } diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/CommissionMemberDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/CommissionMemberDTO.java new file mode 100644 index 0000000..2f5b95a --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/CommissionMemberDTO.java @@ -0,0 +1,31 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.mskobaro.tdms.business.entity.CommissionMemberData; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +public class CommissionMemberDTO { + private Long id; + private ParticipantDTO participant; + private String workPlace; + private String workPosition; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static CommissionMemberDTO from(CommissionMemberData commissionMemberData) { + CommissionMemberDTO dto = new CommissionMemberDTO(); + dto.setId(commissionMemberData.getId()); + dto.setParticipant(ParticipantDTO.fromEntity(commissionMemberData.getParticipant())); + dto.setWorkPlace(commissionMemberData.getWorkPlace()); + dto.setWorkPosition(commissionMemberData.getWorkPosition()); + dto.setCreatedAt(commissionMemberData.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(commissionMemberData.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DefenceDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DefenceDTO.java new file mode 100644 index 0000000..767267d --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DefenceDTO.java @@ -0,0 +1,39 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import ru.mskobaro.tdms.business.entity.Defense; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Setter +public class DefenceDTO { + private Long id; + private List commissionMembers; + private List groups; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static DefenceDTO from(Defense defense) { + DefenceDTO dto = new DefenceDTO(); + dto.setId(defense.getId()); + + if (CollectionUtils.isNotEmpty(defense.getCommissionMembers())) { + dto.setCommissionMembers( + defense.getCommissionMembers().stream() + .map(CommissionMemberDTO::from) + .collect(Collectors.toList()) + ); + } + + dto.setGroups(defense.getGroups().stream().map(g -> GroupDTO.from(g, true)).toList()); + dto.setCreatedAt(defense.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(defense.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DiplomaTopicDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DiplomaTopicDTO.java new file mode 100644 index 0000000..33f72a6 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/DiplomaTopicDTO.java @@ -0,0 +1,37 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import ru.mskobaro.tdms.business.entity.DiplomaTopic; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@ToString +public class DiplomaTopicDTO { + private Long id; + private String name; + private TeacherDataDTO teacher; + private PreparationDirectionDTO preparationDirection; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static DiplomaTopicDTO from(DiplomaTopic diplomaTopic) { + DiplomaTopicDTO dto = new DiplomaTopicDTO(); + dto.setId(diplomaTopic.getId()); + dto.setName(diplomaTopic.getName()); + if (diplomaTopic.getTeacher() != null) { + dto.setTeacher(TeacherDataDTO.from(diplomaTopic.getTeacher())); + } + if (diplomaTopic.getDirectionOfPreparation() != null) { + dto.setPreparationDirection(PreparationDirectionDTO.from(diplomaTopic.getDirectionOfPreparation())); + } + dto.setCreatedAt(diplomaTopic.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(diplomaTopic.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/ErrorDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ErrorDTO.java similarity index 90% rename from server/src/main/java/ru/mskobaro/tdms/presentation/payload/ErrorDTO.java rename to server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ErrorDTO.java index 895fe7b..d099897 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/ErrorDTO.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ErrorDTO.java @@ -1,4 +1,4 @@ -package ru.mskobaro.tdms.presentation.payload; +package ru.mskobaro.tdms.presentation.controller.payload; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/GroupDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/GroupDTO.java new file mode 100644 index 0000000..b8fcf21 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/GroupDTO.java @@ -0,0 +1,47 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import ru.mskobaro.tdms.business.entity.Group; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@ToString +public class GroupDTO { + private Long id; + @NotEmpty(message = "Имя группы не может быть пустым") + @Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов") + @Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры") + private String name; + private List students; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private PreparationDirectionDTO preparationDirection; + + public static GroupDTO from(Group group, boolean includeStudents) { + GroupDTO dto = new GroupDTO(); + dto.setId(group.getId()); + dto.setName(group.getName()); + if (includeStudents && group.getStudents() != null) { + dto.setStudents( + group.getStudents() + .stream() + .filter(sd -> !sd.getParticipant().isDeleted()) + .map(sd -> StudentDataDTO.from(sd, false)) + .toList()); + } + if (group.getDirectionOfPreparation() != null) { + dto.setPreparationDirection(PreparationDirectionDTO.from(group.getDirectionOfPreparation())); + } + dto.setCreatedAt(group.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(group.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/IdDto.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/IdDto.java new file mode 100644 index 0000000..98fd6cc --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/IdDto.java @@ -0,0 +1,14 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +@Getter +@Setter +@ToString +public class IdDto { + private Long id; +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/LoginDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/LoginDTO.java similarity index 94% rename from server/src/main/java/ru/mskobaro/tdms/presentation/payload/LoginDTO.java rename to server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/LoginDTO.java index fefbc44..1fcf3eb 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/LoginDTO.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/LoginDTO.java @@ -1,4 +1,4 @@ -package ru.mskobaro.tdms.presentation.payload; +package ru.mskobaro.tdms.presentation.controller.payload; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantDTO.java new file mode 100644 index 0000000..fc7f4e9 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantDTO.java @@ -0,0 +1,48 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import ru.mskobaro.tdms.business.entity.Participant; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; + +@Getter +@Setter +@ToString +@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") +public class ParticipantDTO { + private Long id; + private String firstName; + private String lastName; + private String middleName; + private String email; + private String numberPhone; + private UserDTO user; + private List roles; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ParticipantDTO fromEntity(Participant participant) { + ParticipantDTO participantDTO = new ParticipantDTO(); + participantDTO.setId(participant.getId()); + participantDTO.setFirstName(participant.getFirstName()); + participantDTO.setLastName(participant.getLastName()); + participantDTO.setMiddleName(participant.getMiddleName()); + participantDTO.setEmail(participant.getEmail()); + participantDTO.setNumberPhone(participant.getNumberPhone()); + participantDTO.setRoles(RoleDTO.from(participant)); + participantDTO.setCreatedAt(participant.getAuditInfo().getCreatedAt()); + participantDTO.setUpdatedAt(participant.getAuditInfo().getUpdatedAt()); + + if (participant.getUser() != null) { + participantDTO.setUser(UserDTO.fromEntity(participant.getUser(), participantDTO)); + } + + return participantDTO; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantSaveDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantSaveDTO.java new file mode 100644 index 0000000..a9b5fbb --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/ParticipantSaveDTO.java @@ -0,0 +1,64 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.ToString; +import ru.mskobaro.tdms.business.service.RoleService.Authority; + +import java.util.List; + +@Getter +@ToString +public class ParticipantSaveDTO { + private Long id; + @NotEmpty(message = "Имя не может быть пустым") + @Pattern(regexp = "^[a-zA-Zа-яА-ЯёЁ\\s-]+$", message = "Имя должно содержать только буквы английского или русского алфавита, пробелы и дефис") + private String firstName; + @NotEmpty(message = "Фамилия не может быть пустой") + @Pattern(regexp = "^[a-zA-Zа-яА-ЯёЁ\\s-]+$", message = "Фамилия должна содержать только буквы английского или русского алфавита, пробелы и дефис") + private String lastName; + private String middleName; + @NotNull(message = "Почта не может быть пустой") + @Email(message = "Почта должна быть валидным адресом электронной почты") + private String email; + @NotNull(message = "Номер телефона не может быть пустым") + @Pattern(regexp = "^\\+[1-9]\\d{6,14}$", message = "Номер телефона должен начинаться с '+' и содержать от 7 до 15 цифр") + private String numberPhone; + private List authorities; + + @Valid + private UserRegistrationDTO userData; + @Valid + private StudentRegistrationDTO studentData; + @Valid + private TeacherRegistrationDTO teacherData; + + @Getter + @ToString + public static class UserRegistrationDTO { + @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Логин должен содержать только латинские буквы, цифры и знак подчеркивания") + @Size(min = 5, message = "Логин должен содержать минимум 5 символов") + @Size(max = 32, message = "Логин должен содержать максимум 32 символов") + private String login; + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов") + private String password; + } + + @Getter + @ToString + public static class StudentRegistrationDTO { + private Long groupId; + private Long curatorId; + private Long diplomaTopicId; + } + + @Getter + @ToString + public static class TeacherRegistrationDTO { + private List curatingGroups; + private List advisingStudents; + private String degree; + } + +} \ No newline at end of file diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/PreparationDirectionDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/PreparationDirectionDTO.java new file mode 100644 index 0000000..7568ed4 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/PreparationDirectionDTO.java @@ -0,0 +1,29 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.mskobaro.tdms.business.entity.DirectionOfPreparation; + +import java.time.LocalDateTime; + +@NoArgsConstructor +@Getter +@Setter +public class PreparationDirectionDTO { + private Long id; + private String name; + private String code; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static PreparationDirectionDTO from(DirectionOfPreparation preparationDirection) { + PreparationDirectionDTO dto = new PreparationDirectionDTO(); + dto.setId(preparationDirection.getId()); + dto.setName(preparationDirection.getName()); + dto.setCode(preparationDirection.getCode()); + dto.setCreatedAt(preparationDirection.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(preparationDirection.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/RoleDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/RoleDTO.java new file mode 100644 index 0000000..8192de3 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/RoleDTO.java @@ -0,0 +1,19 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + + +import ru.mskobaro.tdms.business.entity.Participant; +import ru.mskobaro.tdms.business.entity.Role; + +import java.util.List; + + +public record RoleDTO(String name, String code) { + + public static RoleDTO from(Role role) { + return new RoleDTO(role.getName(), role.getAuthority()); + } + + public static List from(Participant participant) { + return participant.getRoles().stream().map(RoleDTO::from).toList(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/StudentDataDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/StudentDataDTO.java new file mode 100644 index 0000000..bdd2173 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/StudentDataDTO.java @@ -0,0 +1,31 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.Setter; +import ru.mskobaro.tdms.business.entity.StudentData; + +@Setter +@Getter +public class StudentDataDTO { + private Long id; + private GroupDTO group; + private ParticipantDTO participant; + private TeacherDataDTO curator; + private DiplomaTopicDTO diplomaTopic; + + public static StudentDataDTO from(StudentData studentData, boolean includeGroup) { + StudentDataDTO dto = new StudentDataDTO(); + dto.setId(studentData.getId()); + if (includeGroup && studentData.getGroup() != null) { + dto.setGroup(GroupDTO.from(studentData.getGroup(), false)); + } + dto.setParticipant(ParticipantDTO.fromEntity(studentData.getParticipant())); + if (studentData.getCurator() != null) { + dto.setCurator(TeacherDataDTO.from(studentData.getCurator())); + } + if (studentData.getDiplomaTopic() != null) { + dto.setDiplomaTopic(DiplomaTopicDTO.from(studentData.getDiplomaTopic())); + } + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TaskDto.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TaskDto.java new file mode 100644 index 0000000..feba773 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TaskDto.java @@ -0,0 +1,33 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.mskobaro.tdms.business.entity.Task; +import ru.mskobaro.tdms.business.taskfields.TaskFields; + +import java.time.LocalDateTime; + +@NoArgsConstructor +@Getter +@Setter +public class TaskDto { + private Long id; + private Task.Type type; + private Task.Status status; + private TaskFields fields; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + + public static TaskDto from(Task task) { + TaskDto dto = new TaskDto(); + dto.setId(task.getId()); + dto.setType(task.getType()); + dto.setStatus(task.getStatus()); + dto.setFields(task.getFields()); + dto.setCreatedAt(task.getAuditInfo().getCreatedAt()); + dto.setUpdatedAt(task.getAuditInfo().getUpdatedAt()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TeacherDataDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TeacherDataDTO.java new file mode 100644 index 0000000..f79ebe1 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/TeacherDataDTO.java @@ -0,0 +1,27 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.mskobaro.tdms.business.entity.TeacherData; + +@Getter +@Setter +@NoArgsConstructor +public class TeacherDataDTO { + private Long id; + private ParticipantDTO participant; + private String degree; + private String createdAt; + private String updatedAt; + + public static TeacherDataDTO from(TeacherData teacherData) { + TeacherDataDTO dto = new TeacherDataDTO(); + dto.setId(teacherData.getId()); + dto.setParticipant(ParticipantDTO.fromEntity(teacherData.getParticipant())); + dto.setDegree(teacherData.getDegree()); + dto.setCreatedAt(teacherData.getAuditInfo().getCreatedAt().toString()); + dto.setUpdatedAt(teacherData.getAuditInfo().getUpdatedAt().toString()); + return dto; + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/UserDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/UserDTO.java new file mode 100644 index 0000000..59df214 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/controller/payload/UserDTO.java @@ -0,0 +1,41 @@ +package ru.mskobaro.tdms.presentation.controller.payload; + + +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; +import lombok.Builder; +import ru.mskobaro.tdms.business.entity.User; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + + +@Builder +@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") +public record UserDTO( + Long id, + String login, + ParticipantDTO participant, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static UserDTO fromEntity(User user) { + return UserDTO.builder() + .id(user.getId()) + .login(user.getLogin()) + .participant(ParticipantDTO.fromEntity(user.getParticipant())) + .createdAt(user.getAuditInfo().getCreatedAt()) + .updatedAt(user.getAuditInfo().getUpdatedAt()) + .build(); + } + + public static UserDTO fromEntity(User user, ParticipantDTO participant) { + return UserDTO.builder() + .id(user.getId()) + .login(user.getLogin()) + .participant(participant) + .createdAt(user.getAuditInfo().getCreatedAt()) + .updatedAt(user.getAuditInfo().getUpdatedAt()) + .build(); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/exception/ApplicationExceptionHandler.java b/server/src/main/java/ru/mskobaro/tdms/presentation/exception/ApplicationExceptionHandler.java index f672711..84cbeb3 100644 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/exception/ApplicationExceptionHandler.java +++ b/server/src/main/java/ru/mskobaro/tdms/presentation/exception/ApplicationExceptionHandler.java @@ -9,16 +9,16 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; -import ru.mskobaro.tdms.domain.exception.AccessDeniedException; -import ru.mskobaro.tdms.domain.exception.BusinessException; -import ru.mskobaro.tdms.presentation.payload.ErrorDTO; +import ru.mskobaro.tdms.business.exception.AccessDeniedException; +import ru.mskobaro.tdms.business.exception.BusinessException; +import ru.mskobaro.tdms.presentation.controller.payload.ErrorDTO; import java.util.UUID; import java.util.stream.Collectors; import static org.springframework.http.HttpStatus.*; import static org.springframework.http.HttpStatus.NOT_FOUND; -import static ru.mskobaro.tdms.presentation.payload.ErrorDTO.ErrorCode.*; +import static ru.mskobaro.tdms.presentation.controller.payload.ErrorDTO.ErrorCode.*; @RestControllerAdvice @Slf4j diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupCreateDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupCreateDTO.java deleted file mode 100644 index 2591364..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupCreateDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.Getter; - -@Getter -public class GroupCreateDTO { - @NotEmpty(message = "Имя группы не может быть пустым") - @Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов") - @Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры") - private String name; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupDTO.java deleted file mode 100644 index ee07feb..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupDTO.java +++ /dev/null @@ -1,15 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - -@Getter -@Setter -@ToString -public class GroupDTO { - private Long id; - private String name; - private String curatorName; - private Boolean iAmCurator; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupEditDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupEditDTO.java deleted file mode 100644 index 1fc54f0..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/GroupEditDTO.java +++ /dev/null @@ -1,17 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - -import jakarta.validation.constraints.*; -import lombok.Getter; -import lombok.ToString; - -@Getter -@ToString -public class GroupEditDTO { - @NotNull(message = "Идентификатор группы не может быть пустым") - @Min(value = 1, message = "Идентификатор группы должен быть больше 0") - private Long id; - @NotEmpty(message = "Имя группы не может быть пустым") - @Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов") - @Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры") - private String name; -} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RegistrationDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RegistrationDTO.java deleted file mode 100644 index d47a973..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RegistrationDTO.java +++ /dev/null @@ -1,54 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - -import jakarta.validation.constraints.*; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; -import org.hibernate.validator.constraints.Length; -import ru.mskobaro.tdms.domain.service.RoleService.Authority; - -import java.util.List; - -@Getter -@ToString -public class RegistrationDTO { - @NotEmpty(message = "Логин не может быть пустым") - @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Логин должен содержать только латинские буквы, цифры и знак подчеркивания") - @Size(min = 5, message = "Логин должен содержать минимум 5 символов") - @Size(max = 32, message = "Логин должен содержать максимум 32 символов") - private String login; - @NotEmpty(message = "Пароль не может быть пустым") - @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов") - private String password; - @NotEmpty(message = "Имя не может быть пустым") - @Length(min = 3, message = "Имя должно содержать минимум 3 символа") - @Pattern(regexp = "^[a-zA-Zа-яА-ЯёЁ\\s]+$", message = "Имя должно содержать только буквы английского или русского алфавита и пробелы") - private String fullName; - @NotNull(message = "Почта не может быть пустой") - @Email(message = "Почта должна быть валидным адресом электронной почты") - private String email; - @NotNull(message = "Номер телефона не может быть пустым") - @Pattern(regexp = "^\\+[1-9]\\d{6,14}$", message = "Номер телефона должен начинаться с '+' и содержать от 7 до 15 цифр") - private String numberPhone; - @NotNull(message = "Тип аккаунта не может быть пустым") - private Authority accountType; - - private StudentRegistrationDTO studentData; - private TeacherRegistrationDTO teacherData; - - @Getter - @ToString - public static class StudentRegistrationDTO { - @NotNull(message = "Группа не может быть пустой") - private Long groupId; - } - - @Getter - @Setter - @ToString - public static class TeacherRegistrationDTO { - private List curatingGroups; - private List advisingStudents; - } - -} \ No newline at end of file diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RoleDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RoleDTO.java deleted file mode 100644 index aacf797..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/RoleDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - - -import ru.mskobaro.tdms.domain.entity.Role; -import ru.mskobaro.tdms.domain.entity.User; - -import java.util.List; - - -public record RoleDTO(String name, String authority) { - - public static RoleDTO from(Role role) { - return new RoleDTO(role.getName(), role.getAuthority()); - } - - public static List from(User user) { - return user.getRoles().stream().map(RoleDTO::from).toList(); - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/StudentDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/StudentDTO.java deleted file mode 100644 index 6cc4d74..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/StudentDTO.java +++ /dev/null @@ -1,47 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - - -import lombok.Data; -import ru.mskobaro.tdms.domain.entity.Student; - - -@Data -public class StudentDTO { - private Long id; - private Boolean form; - private Integer protectionOrder; - private String magistracy; - private Boolean digitalFormatPresent; - private Integer markComment; - private Integer markPractice; - private String predefenceComment; - private String normalControl; - private Integer antiPlagiarism; - private String note; - private Boolean recordBookReturned; - private String work; - private UserDTO user; - private String diplomaTopic; - private UserDTO mentorUser; - private GroupDTO group; - - public static StudentDTO from(Student student) { - StudentDTO studentDTO = new StudentDTO(); - studentDTO.setId(student.getId()); - studentDTO.setForm(student.getForm()); - studentDTO.setProtectionOrder(student.getProtectionOrder()); - studentDTO.setMagistracy(student.getMagistracy()); - studentDTO.setDigitalFormatPresent(student.getDigitalFormatPresent()); - studentDTO.setMarkComment(student.getMarkComment()); - studentDTO.setMarkPractice(student.getMarkPractice()); - studentDTO.setPredefenceComment(student.getPredefenceComment()); - studentDTO.setNormalControl(student.getNormalControl()); - studentDTO.setAntiPlagiarism(student.getAntiPlagiarism()); - studentDTO.setNote(student.getNote()); - studentDTO.setRecordBookReturned(student.getRecordBookReturned()); - studentDTO.setWork(student.getWork()); - studentDTO.setUser(UserDTO.from(student.getUser())); - studentDTO.setDiplomaTopic(student.getDiplomaTopic().getName()); - return studentDTO; - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/UserDTO.java b/server/src/main/java/ru/mskobaro/tdms/presentation/payload/UserDTO.java deleted file mode 100644 index eb2cbc6..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/presentation/payload/UserDTO.java +++ /dev/null @@ -1,45 +0,0 @@ -package ru.mskobaro.tdms.presentation.payload; - - -import com.fasterxml.jackson.annotation.JsonInclude; -import lombok.Builder; -import org.springframework.security.core.GrantedAuthority; -import ru.mskobaro.tdms.domain.entity.User; - -import java.time.ZonedDateTime; -import java.util.List; - - -@Builder -@JsonInclude(JsonInclude.Include.NON_ABSENT) -public record UserDTO( - Long id, - boolean authenticated, - String login, - String fullName, - String email, - String phone, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - List authorities -) { - public static UserDTO unauthenticated() { - return UserDTO.builder() - .authenticated(false) - .build(); - } - - public static UserDTO from(User user) { - return UserDTO.builder() - .id(user.getId()) - .authenticated(true) - .login(user.getLogin()) - .fullName(user.getFullName()) - .email(user.getEmail()) - .phone(user.getNumberPhone()) - .createdAt(user.getCreatedAt()) - .updatedAt(user.getUpdatedAt()) - .authorities(user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()) - .build(); - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/system/config/SecurityConfig.java b/server/src/main/java/ru/mskobaro/tdms/system/config/SecurityConfig.java index 074f9ff..78d945e 100644 --- a/server/src/main/java/ru/mskobaro/tdms/system/config/SecurityConfig.java +++ b/server/src/main/java/ru/mskobaro/tdms/system/config/SecurityConfig.java @@ -11,31 +11,28 @@ import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.ExceptionTranslationFilter; -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.context.SecurityContextHolderFilter; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; -import ru.mskobaro.tdms.system.web.DevAuthenticationRequestFilter; import ru.mskobaro.tdms.system.web.LoggingRequestFilter; import java.time.Duration; import java.util.List; -import static ru.mskobaro.tdms.domain.service.RoleService.Authority.*; +import static ru.mskobaro.tdms.business.service.RoleService.Authority.ADMIN; +import static ru.mskobaro.tdms.business.service.RoleService.Authority.SECRETARY; @Slf4j @@ -45,27 +42,31 @@ public class SecurityConfig { private Environment environment; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, AuthenticationManager authenticationManager, CorsConfigurationSource cors) throws Exception { - if (environment.matchesProfiles("dev")) { - httpSecurity.addFilterAfter(new DevAuthenticationRequestFilter(), SecurityContextHolderFilter.class); - } - + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, + AuthenticationManager authenticationManager, + CorsConfigurationSource cors + ) throws Exception { return httpSecurity - .addFilterAfter(new LoggingRequestFilter(), AnonymousAuthenticationFilter.class) - .authorizeHttpRequests(this::configureHttpAuthorization) - .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) - .cors(a -> a.configurationSource(cors)) - .authenticationManager(authenticationManager) - .sessionManagement(cfg -> { - cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER); - cfg.maximumSessions(1); - }) - .build(); + .addFilterAfter(new LoggingRequestFilter(), SecurityContextHolderFilter.class) + .authorizeHttpRequests(this::configureHttpAuthorization) + // .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .csrf(AbstractHttpConfigurer::disable) + .cors(a -> a.configurationSource(cors)) + .authenticationManager(authenticationManager) + .sessionManagement(cfg -> { + cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER); + cfg.maximumSessions(1); + }) + .build(); } @Bean @Primary - public CorsConfigurationSource corsConfigurationProd(@Value("${application.domain}") String domain, @Value("${application.port}") String port, @Value("${application.protocol}") String protocol) { + public CorsConfigurationSource corsConfiguration( + @Value("${application.domain}") String domain, + @Value("${application.port}") String port, + @Value("${application.protocol}") String protocol + ) { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name())); corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); @@ -78,14 +79,21 @@ public class SecurityConfig { } log.info("CORS configuration: [headers: {}, methods: {}, origins: {}, credentials: {}, maxAge: {}]", - corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(), - corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge()); + corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(), + corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge()); return request -> corsConfiguration; } @Bean public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) { - return new ProviderManager(authenticationProvider(userDetailsService)); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(passwordEncoder()); + provider.setUserDetailsService(userDetailsService); + return new ProviderManager(provider); + } + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); } @Bean @@ -93,32 +101,42 @@ public class SecurityConfig { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } - private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry httpAuthorization) { - /* SysInfoController */ + private void configureHttpAuthorization( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry httpAuthorization + ) { httpAuthorization.requestMatchers("/api/v1/sysinfo/**").permitAll(); - /* UserController */ + httpAuthorization.requestMatchers("/api/v1/user/logout").authenticated(); - httpAuthorization.requestMatchers("/api/v1/user/login").anonymous(); + httpAuthorization.requestMatchers("/api/v1/user/login").permitAll(); httpAuthorization.requestMatchers("/api/v1/user/current").permitAll(); - httpAuthorization.requestMatchers("/api/v1/user/get-all").hasAuthority(ADMIN.getAuthority()); - httpAuthorization.requestMatchers("/api/v1/user/register").hasAuthority(ADMIN.getAuthority()); - httpAuthorization.requestMatchers("/api/v1/user/validate-registration").hasAuthority(ADMIN.getAuthority()); - /* StudentController */ - httpAuthorization.requestMatchers("/api/v1/student/current").permitAll(); - httpAuthorization.requestMatchers("api/v1/student/by-user-id").hasAnyAuthority( - SECRETARY.getAuthority(), ADMIN.getAuthority(), STUDENT.getAuthority(), TEACHER.getAuthority()); - /* GroupController */ + + httpAuthorization.requestMatchers("/api/v1/participant/get-all-participants").permitAll(); + httpAuthorization.requestMatchers("/api/v1/participant/get-possible-curators").permitAll(); + // Сложная логика авторизации, так что проверяем явно в ParticipantService + httpAuthorization.requestMatchers("/api/v1/participant/save").authenticated(); + httpAuthorization.requestMatchers("/api/v1/participant/delete").hasAuthority(ADMIN.getAuthority()); + httpAuthorization.requestMatchers("/api/v1/group/get-all-groups").permitAll(); - httpAuthorization.requestMatchers("/api/v1/group/create-group").hasAuthority(ADMIN.getAuthority()); - /* deny all other api requests */ + httpAuthorization.requestMatchers("/api/v1/group/save").hasAnyAuthority(ADMIN.getAuthority(), SECRETARY.getAuthority()); + httpAuthorization.requestMatchers("/api/v1/group/delete").hasAnyAuthority(ADMIN.getAuthority()); + + httpAuthorization.requestMatchers("/api/v1/student/by-partic-id").permitAll(); + httpAuthorization.requestMatchers("/api/v1/student/all-without-group").permitAll(); + + httpAuthorization.requestMatchers("/api/v1/teacher-data/get-all").permitAll(); + httpAuthorization.requestMatchers("/api/v1/teacher-data/by-partic-id").permitAll(); + + httpAuthorization.requestMatchers("/api/v1/prep-direction/get-all").permitAll(); + httpAuthorization.requestMatchers("/api/v1/prep-direction/save").hasAnyAuthority(ADMIN.getAuthority(), SECRETARY.getAuthority()); + + httpAuthorization.requestMatchers("/api/v1/defence/all").permitAll(); + + httpAuthorization.requestMatchers("/api/v1/diploma-topic/all").permitAll(); + httpAuthorization.requestMatchers("/api/v1/diploma-topic/all-for-student").permitAll(); + httpAuthorization.requestMatchers("/api/v1/diploma-topic/save").hasAnyAuthority(ADMIN.getAuthority(), SECRETARY.getAuthority()); + httpAuthorization.requestMatchers("/api/**").denyAll(); - /* since api already blocked, all other requests are static resources */ + httpAuthorization.requestMatchers("/**").permitAll(); } - - private AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(passwordEncoder()); - provider.setUserDetailsService(userDetailsService); - return provider; - } } diff --git a/server/src/main/java/ru/mskobaro/tdms/system/config/WebConfig.java b/server/src/main/java/ru/mskobaro/tdms/system/config/WebConfig.java new file mode 100644 index 0000000..d27e8fb --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/system/config/WebConfig.java @@ -0,0 +1,28 @@ +package ru.mskobaro.tdms.system.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +import java.io.IOException; +import org.springframework.core.io.Resource; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, Resource location) throws IOException { + Resource requestedResource = location.createRelative(resourcePath); + return requestedResource.exists() && requestedResource.isReadable() + ? requestedResource + : location.createRelative("index.html"); + } + }); + } +} \ No newline at end of file diff --git a/server/src/main/java/ru/mskobaro/tdms/system/web/AccessDeniedExceptionHandler.java b/server/src/main/java/ru/mskobaro/tdms/system/web/AccessDeniedExceptionHandler.java new file mode 100644 index 0000000..4a85bb8 --- /dev/null +++ b/server/src/main/java/ru/mskobaro/tdms/system/web/AccessDeniedExceptionHandler.java @@ -0,0 +1,20 @@ +package ru.mskobaro.tdms.system.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Component +public class AccessDeniedExceptionHandler implements AccessDeniedHandler { + @Autowired + private HandlerExceptionResolver handlerExceptionResolver; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) { + handlerExceptionResolver.resolveException(request, response, null, new ru.mskobaro.tdms.business.exception.AccessDeniedException()); + } +} diff --git a/server/src/main/java/ru/mskobaro/tdms/system/web/DevAuthenticationRequestFilter.java b/server/src/main/java/ru/mskobaro/tdms/system/web/DevAuthenticationRequestFilter.java deleted file mode 100644 index b709407..0000000 --- a/server/src/main/java/ru/mskobaro/tdms/system/web/DevAuthenticationRequestFilter.java +++ /dev/null @@ -1,46 +0,0 @@ -package ru.mskobaro.tdms.system.web; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.TransientSecurityContext; -import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; -import org.springframework.web.filter.OncePerRequestFilter; -import ru.mskobaro.tdms.domain.entity.Role; -import ru.mskobaro.tdms.domain.entity.User; - -import java.io.IOException; -import java.util.List; - -import static ru.mskobaro.tdms.domain.service.RoleService.Authority.ADMIN; - -@Slf4j -public class DevAuthenticationRequestFilter extends OncePerRequestFilter { - public DevAuthenticationRequestFilter() { - log.warn("!!!ANY REQUEST WILL BE AUTHENTICATED AS DEV_ADMIN, IF YOU SEE THIS IN PRODUCTION, CHECK YOUR CONFIGURATION!!!"); - log.warn("!!!ANY REQUEST WILL BE AUTHENTICATED AS DEV_ADMIN, IF YOU SEE THIS IN PRODUCTION, CHECK YOUR CONFIGURATION!!!"); - log.warn("!!!ANY REQUEST WILL BE AUTHENTICATED AS DEV_ADMIN, IF YOU SEE THIS IN PRODUCTION, CHECK YOUR CONFIGURATION!!!"); - log.warn("!!!ANY REQUEST WILL BE AUTHENTICATED AS DEV_ADMIN, IF YOU SEE THIS IN PRODUCTION, CHECK YOUR CONFIGURATION!!!"); - log.warn("!!!ANY REQUEST WILL BE AUTHENTICATED AS DEV_ADMIN, IF YOU SEE THIS IN PRODUCTION, CHECK YOUR CONFIGURATION!!!"); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - Role role = new Role(-1L, "dev_admin_role", ADMIN.getAuthority()); - User admin = new User(); - admin.setRoles(List.of(role)); - admin.setId(-1L); - admin.setLogin("dev_admin"); - admin.setEmail("dev_admin@main.mail"); - admin.setFullName("dev_admin"); - admin.setNumberPhone("+79999999999"); - admin.setPassword("{bcrypt}$2a$06$BHHQMjwQB2KI9sDdC9rRHOuYkTskjDt9WAyrscWP/Dcn7my3Jr77K"); - var auth = new PreAuthenticatedAuthenticationToken(admin, null, admin.getAuthorities()); - var context = new TransientSecurityContext(auth); - SecurityContextHolder.setContext(context); - filterChain.doFilter(request, response); - } -} diff --git a/server/src/main/java/ru/mskobaro/tdms/system/web/LoggingRequestFilter.java b/server/src/main/java/ru/mskobaro/tdms/system/web/LoggingRequestFilter.java index 062f8b5..01397de 100644 --- a/server/src/main/java/ru/mskobaro/tdms/system/web/LoggingRequestFilter.java +++ b/server/src/main/java/ru/mskobaro/tdms/system/web/LoggingRequestFilter.java @@ -6,24 +6,30 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.UUID; @Slf4j public class LoggingRequestFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + UUID uuid = UUID.randomUUID(); long startTime = System.currentTimeMillis(); + String username = SecurityContextHolder.getContext().getAuthentication() != null ? + SecurityContextHolder.getContext().getAuthentication().getName() : "anonymousUser"; HttpSession session = request.getSession(false); - log.info("Request received: [{}] {} user: {}, session: {}, remote ip: {}", - request.getMethod(), request.getRequestURI(), request.getRemoteUser(), - session == null ? "no" : session.getId(), request.getRemoteAddr()); + log.info("Request received: [{}] {} user: {}, session: {}, remote ip: {} [{}]", + request.getMethod(), request.getRequestURI(), username, + session == null ? "no" : session.getId(), request.getRemoteAddr(), uuid); try { filterChain.doFilter(request, response); } finally { long duration = System.currentTimeMillis() - startTime; - log.info("Response with {} status duration: {} ms", response.getStatus(), duration); + log.info("Response with {} status duration: {} ms [{}]", response.getStatus(), duration, uuid); } } } diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 72731bc..2649fb3 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -30,11 +30,6 @@ spring: schemas: ${db.schema} banner: location: banner.txt - web: - resources: - static-locations: classpath:/static/ -# autoconfigure: -# exclude: org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration server: port: ${application.port} address: ${application.domain} diff --git a/server/src/main/resources/db/migration/V00000__Initial_schema.sql b/server/src/main/resources/db/migration/V00000__Initial_schema.sql new file mode 100644 index 0000000..22f5225 --- /dev/null +++ b/server/src/main/resources/db/migration/V00000__Initial_schema.sql @@ -0,0 +1,280 @@ +CREATE TABLE defense +( + id BIGSERIAL PRIMARY KEY, + defense_date DATE, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE defense_best_student_works +( + defense_id BIGINT, + student_data_id BIGINT +); + +CREATE TABLE defense_commission +( + defense_id BIGINT, + commission_member_data_id BIGINT +); + +CREATE TABLE diploma_topic +( + id BIGSERIAL PRIMARY KEY, + name TEXT, + teacher_id BIGINT, + direction_of_preparation_id BIGINT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE direction_of_preparation +( + id BIGSERIAL PRIMARY KEY, + name TEXT, + code TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE "group" +( + id BIGSERIAL PRIMARY KEY, + name TEXT, + defense_id BIGINT, + direction_of_preparation_id BIGINT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE participant +( + id BIGSERIAL PRIMARY KEY, + first_name TEXT, + last_name TEXT, + middle_name TEXT, + email TEXT, + number_phone TEXT, + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE participant_role +( + partic_id BIGINT NOT NULL, + role_id BIGINT NOT NULL +); + +CREATE TABLE role +( + id BIGINT PRIMARY KEY, + name TEXT, + authority TEXT +); + +/* todo */ +CREATE TABLE student_data +( + id BIGSERIAL PRIMARY KEY, + partic_id BIGINT, + study_form_id BIGINT, + + curator_id BIGINT, + protection_order INTEGER, + protection_day INTEGER, + + mark_comment INTEGER, + mark_practice INTEGER, + + predefnese_comment TEXT, + normal_control BOOLEAN, + anti_plagiarism INTEGER, + record_book_returned BOOLEAN, + work TEXT, + diploma_topic_id BIGINT, + adviser_teacher_partic_id BIGINT, + group_id BIGINT, + marks_3 BIGINT, + marks_4 BIGINT, + marks_5 BIGINT, + commission_mark BIGINT, + estimated BOOLEAN, + diploma_with_honors BOOLEAN, + magistracy_recommendation BOOLEAN, + magistracy_wanted BOOLEAN, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +/* not implemented */ +create table questionnaire +( + id BIGSERIAL PRIMARY KEY, + student_data_id BIGINT, + data TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +create table study_form +( + id BIGSERIAL PRIMARY KEY, + name TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +/* todo */ +create table stud_comment +( + id BIGSERIAL PRIMARY KEY, + comment TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +/* todo */ +create table student_data_comment +( + id BIGSERIAL PRIMARY KEY, + student_data_id BIGINT, + stud_comment_id BIGINT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE teacher_data +( + id BIGSERIAL PRIMARY KEY, + participant_id BIGINT, + degree TEXT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE commission_member_data +( + id BIGSERIAL PRIMARY KEY, + partic_id BIGINT, + work_place TEXT, + work_position TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE "user" +( + id BIGSERIAL PRIMARY KEY, + login TEXT, + password TEXT, + partic_id BIGINT, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE TABLE task +( + id BIGSERIAL PRIMARY KEY, + type TEXT, + status TEXT, + fields jsonb, + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +ALTER TABLE defense_commission + ADD CONSTRAINT FK_DEFCOM_ON_DEFNESE FOREIGN KEY (defense_id) REFERENCES defense (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE defense_commission + ADD CONSTRAINT FK_DEFCOM_ON_COMMEMD FOREIGN KEY (commission_member_data_id) REFERENCES commission_member_data (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "group" + ADD CONSTRAINT FK_GROUP_ON_defnese FOREIGN KEY (defense_id) REFERENCES defense (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "group" + ADD CONSTRAINT FK_GROUP_ON_DOP FOREIGN KEY (direction_of_preparation_id) REFERENCES direction_of_preparation (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE participant_role + ADD CONSTRAINT FK_PARROL_ON_PARTICIPANT FOREIGN KEY (partic_id) REFERENCES participant (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE participant_role + ADD CONSTRAINT FK_PARROL_ON_ROLE FOREIGN KEY (role_id) REFERENCES role (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT UC_STUDENT_DATA_PARTIC UNIQUE (partic_id); + +ALTER TABLE student_data + ADD CONSTRAINT FK_STUDENT_DATA_ON_ADVISER_TEACHER_PARTIC FOREIGN KEY (adviser_teacher_partic_id) REFERENCES participant (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT FK_STUDENT_DATA_ON_DIPLOMA_TOPIC FOREIGN KEY (diploma_topic_id) REFERENCES diploma_topic (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT FK_STUDENT_DATA_ON_GROUP FOREIGN KEY (group_id) REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT FK_STUDENT_DATA_ON_PARTIC FOREIGN KEY (partic_id) REFERENCES participant (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE participant + ADD CONSTRAINT UC_PARTICIPANT_EMAIL UNIQUE (email); + +ALTER TABLE participant + ADD CONSTRAINT UC_PARTICIPANT_NUMBER_PHONE UNIQUE (number_phone); + +ALTER TABLE "user" + ADD CONSTRAINT UC_USER_LOGIN UNIQUE (login); + +ALTER TABLE "user" + ADD CONSTRAINT UC_USER_PARTIC UNIQUE (partic_id); + +ALTER TABLE "user" + ADD CONSTRAINT FK_USER_ON_PARTIC FOREIGN KEY (partic_id) REFERENCES participant (id); + +ALTER TABLE teacher_data + ADD CONSTRAINT UC_TEACHER_DATA_PARTIC UNIQUE (participant_id); + +ALTER TABLE teacher_data + ADD CONSTRAINT FK_TEACHER_DATA_ON_PARTIC FOREIGN KEY (participant_id) REFERENCES participant (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE commission_member_data + ADD CONSTRAINT UC_COMMISION_MEMBER_DATA_PARTIC UNIQUE (partic_id); + +ALTER TABLE commission_member_data + ADD CONSTRAINT FK_COMMISION_MEMBER_DATA_ON_PARTIC FOREIGN KEY (partic_id) REFERENCES participant (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE direction_of_preparation + ADD CONSTRAINT UC_DIRECTION_OF_PREPARATION_NAME UNIQUE (name); + +ALTER TABLE direction_of_preparation + ADD CONSTRAINT UC_DIRECTION_OF_PREPARATION_CODE UNIQUE (code); + +ALTER TABLE diploma_topic + ADD CONSTRAINT UC_DIPLOMA_TOPIC_NAME FOREIGN KEY (direction_of_preparation_id) REFERENCES direction_of_preparation (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE defense_best_student_works + ADD CONSTRAINT FK_DEFENSE_BEST_STUDENT_WORKS_ON_DEFENSE FOREIGN KEY (defense_id) REFERENCES defense (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE defense_best_student_works + ADD CONSTRAINT FK_DEFENSE_BEST_STUDENT_WORKS_ON_STUDENT_DATA FOREIGN KEY (student_data_id) REFERENCES student_data (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE stud_comment + ADD CONSTRAINT UC_STUD_COMMENT_COMMENT UNIQUE (comment); + +ALTER TABLE student_data + ADD CONSTRAINT UC_STUDENT_DATA_DIPLOMA_TOPIC FOREIGN KEY (diploma_topic_id) REFERENCES diploma_topic (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT UC_STUDENT_DATA_STUDY_FORM FOREIGN KEY (study_form_id) REFERENCES study_form (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE student_data + ADD CONSTRAINT UC_STUDENT_DATA_CURATOR FOREIGN KEY (curator_id) REFERENCES teacher_data (id) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE study_form + ADD CONSTRAINT UC_STUDY_FORM_NAME UNIQUE (name); + +alter table questionnaire + add constraint UC_QUESTIONNAIRE_STUDENT_DATA_ID unique (student_data_id); + +alter table questionnaire + add constraint FK_QUESTIONNAIRE_STUDENT_DATA foreign key (student_data_id) references student_data (id) on delete cascade on update cascade; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V00010__Create__role_table.sql b/server/src/main/resources/db/migration/V00010__Create__role_table.sql deleted file mode 100644 index 90f769c..0000000 --- a/server/src/main/resources/db/migration/V00010__Create__role_table.sql +++ /dev/null @@ -1,13 +0,0 @@ -create table role -( - id bigint primary key, - - name text not null unique, - authority text not null unique -); - --- COMMENTS -comment on table role is 'Таблица ролей пользователей'; - -comment on column role.name is 'Человекочитаемое имя роли'; -comment on column role.authority is 'Имя роли в системе'; diff --git a/server/src/main/resources/db/migration/V00020__Create__user_table.sql b/server/src/main/resources/db/migration/V00020__Create__user_table.sql deleted file mode 100644 index a8b6962..0000000 --- a/server/src/main/resources/db/migration/V00020__Create__user_table.sql +++ /dev/null @@ -1,22 +0,0 @@ -create table "user" -( - id bigserial primary key, - - login text not null unique, - password text not null, - full_name text not null, - email text not null unique, - number_phone text not null unique, - - created_at timestamptz not null, - updated_at timestamptz -); - --- COMMENTS -comment on table "user" is 'Таблица пользователей'; - -comment on column "user".login is 'Логин пользователя'; -comment on column "user".password is 'Пароль пользователя'; -comment on column "user".full_name is 'Полное имя пользователя в формате Фамилия Имя Отчество'; -comment on column "user".email is 'Почта пользователя'; -comment on column "user".number_phone is 'Номер телефона пользователя'; diff --git a/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql b/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql deleted file mode 100644 index 1082a87..0000000 --- a/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql +++ /dev/null @@ -1,24 +0,0 @@ -create table user_role -( - id bigserial primary key, - - user_id bigint not null, - role_id bigint not null -); - --- FOREIGN KEY -alter table user_role - add constraint fk_user_role_user_id - foreign key (user_id) references "user" (id) - on delete cascade on update cascade; - -alter table user_role - add constraint fk_user_role_role_id - foreign key (role_id) references role (id) - on delete restrict on update cascade; - --- COMMENTS -comment on table user_role is 'Таблица связи пользователей и ролей'; - -comment on column user_role.user_id is 'Идентификатор пользователя'; -comment on column user_role.role_id is 'Идентификатор роли'; diff --git a/server/src/main/resources/db/migration/V00040__Create__diploma_topic_table.sql b/server/src/main/resources/db/migration/V00040__Create__diploma_topic_table.sql deleted file mode 100644 index 4c9d3c9..0000000 --- a/server/src/main/resources/db/migration/V00040__Create__diploma_topic_table.sql +++ /dev/null @@ -1,11 +0,0 @@ -create table diploma_topic -( - id bigserial primary key, - - name text not null -); - --- COMMENTS -comment on table diploma_topic is 'Таблица тем дипломных работ'; - -comment on column diploma_topic.name is 'Название темы дипломной работы'; diff --git a/server/src/main/resources/db/migration/V00050__Create__teacher_table.sql b/server/src/main/resources/db/migration/V00050__Create__teacher_table.sql deleted file mode 100644 index 8d7df2f..0000000 --- a/server/src/main/resources/db/migration/V00050__Create__teacher_table.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table teacher -( - id bigserial primary key, - user_id bigint not null, - - created_at timestamptz not null, - updated_at timestamptz -); - --- FOREIGN KEY -alter table teacher - add constraint fk_teacher_user_id - foreign key (user_id) references "user" (id) - on delete cascade on update cascade; - --- COMMENTS -comment on table teacher is 'Таблица преподавателей'; - -comment on column teacher.user_id is 'Идентификатор пользователя'; diff --git a/server/src/main/resources/db/migration/V00060__Create__group_table.sql b/server/src/main/resources/db/migration/V00060__Create__group_table.sql deleted file mode 100644 index 4246453..0000000 --- a/server/src/main/resources/db/migration/V00060__Create__group_table.sql +++ /dev/null @@ -1,22 +0,0 @@ -create table "group" -( - id bigserial primary key, - - name text not null unique, - curator_teacher_id bigint, - - created_at timestamptz not null, - updated_at timestamptz -); - --- FOREIGN KEY -alter table "group" - add constraint fk_group_curator_teacher_id - foreign key (curator_teacher_id) references teacher (id) - on delete set null on update cascade; - --- COMMENTS -comment on table "group" is 'Таблица групп студентов'; - -comment on column "group".name is 'Название группы'; -comment on column "group".curator_teacher_id is 'Идентификатор куратора группы'; diff --git a/server/src/main/resources/db/migration/V00070__Create__student_table.sql b/server/src/main/resources/db/migration/V00070__Create__student_table.sql deleted file mode 100644 index a7739c3..0000000 --- a/server/src/main/resources/db/migration/V00070__Create__student_table.sql +++ /dev/null @@ -1,65 +0,0 @@ -create table student -( - id bigserial primary key, - user_id bigint not null, - diploma_topic_id bigint, - adviser_teacher_id bigint, - group_id bigint, - - form boolean, - protection_day int, - protection_order int, - magistracy text, - digital_format_present boolean, - mark_comment int, - mark_practice int, - predefence_comment text, - normal_control text, - anti_plagiarism int, - note text, - record_book_returned boolean, - work text, - - created_at timestamptz not null, - updated_at timestamptz -); - --- FOREIGN KEY -alter table student - add constraint fk_student_user_id - foreign key (user_id) references "user" (id) - on delete cascade on update cascade; -alter table student - add constraint fk_student_diploma_topic_id - foreign key (diploma_topic_id) references diploma_topic (id) - on delete set null on update cascade; -alter table student - add constraint fk_student_adviser_teacher_id - foreign key (adviser_teacher_id) references teacher (id) - on delete set null on update cascade; -alter table student - add constraint fk_student_group_id - foreign key (group_id) references "group" (id) - on delete set null on update cascade; - --- COMMENTS -comment on table student is 'Таблица студентов'; - -comment on column student.user_id is 'Идентификатор пользователя'; -comment on column student.diploma_topic_id is 'Идентификатор темы дипломной работы'; -comment on column student.adviser_teacher_id is 'Идентификатор научного руководителя'; -comment on column student.group_id is 'Идентификатор группы'; - -comment on column student.form is 'Форма обучения'; -comment on column student.protection_day is 'День защиты'; -comment on column student.protection_order is 'Порядок защиты'; -comment on column student.magistracy is 'Магистратура'; -comment on column student.digital_format_present is 'Предоставлен в электронном виде'; -comment on column student.mark_comment is 'Комментарий к оценке'; -comment on column student.mark_practice is 'Оценка практики'; -comment on column student.predefence_comment is 'Комментарий к защите'; -comment on column student.normal_control is 'Обычный контроль'; -comment on column student.anti_plagiarism is 'Антиплагиат'; -comment on column student.note is 'Примечание'; -comment on column student.record_book_returned is 'Ведомость возвращена'; -comment on column student.work is 'Работа'; diff --git a/server/src/main/resources/db/migration/V00500__Insert_default_roles__role.sql b/server/src/main/resources/db/migration/V00500__Insert_default_roles.sql similarity index 67% rename from server/src/main/resources/db/migration/V00500__Insert_default_roles__role.sql rename to server/src/main/resources/db/migration/V00500__Insert_default_roles.sql index a108c7d..8c143c4 100644 --- a/server/src/main/resources/db/migration/V00500__Insert_default_roles__role.sql +++ b/server/src/main/resources/db/migration/V00500__Insert_default_roles.sql @@ -3,4 +3,5 @@ values (1, 'Преподаватель', 'ROLE_TEACHER'), (2, 'Студент', 'ROLE_STUDENT'), (3, 'Член комиссии ГЭК', 'ROLE_COMMISSION_MEMBER'), (4, 'Администратор', 'ROLE_ADMINISTRATOR'), - (5, 'Секретарь', 'ROLE_SECRETARY'); + (5, 'Секретарь', 'ROLE_SECRETARY'), + (6, 'Проверяющий на плагиат', 'ROLE_PLAGIARISM_CHECKER'); diff --git a/server/src/main/resources/db/migration/V00510__Insert_administrator.sql b/server/src/main/resources/db/migration/V00510__Insert_administrator.sql deleted file mode 100644 index d1de6cb..0000000 --- a/server/src/main/resources/db/migration/V00510__Insert_administrator.sql +++ /dev/null @@ -1,8 +0,0 @@ -insert into "user" (id, login, password, full_name, email, number_phone, created_at) -values (1, 'admin', '{noop}Admin000', 'Администратор', 'admin@tdms.tu-byransk.ru', '', now()); - -insert into user_role (id, user_id, role_id) -values (1, 1, 4); - -select setval('user_id_seq', (select max(id) from "user")); -select setval('user_role_id_seq', (select max(id) from user_role)); diff --git a/server/src/main/resources/db/migration/V00510__Insert_system_administrator.sql b/server/src/main/resources/db/migration/V00510__Insert_system_administrator.sql new file mode 100644 index 0000000..e3e769a --- /dev/null +++ b/server/src/main/resources/db/migration/V00510__Insert_system_administrator.sql @@ -0,0 +1,21 @@ +insert into participant (id, first_name, email, number_phone, created_at, updated_at) +values (1, + 'Администратор', + 'admin@tdms.tu-bryansk.ru', + '+74832580058', + now(), + now()); + +insert into "user" (id, login, password, partic_id, created_at, updated_at) +values (1, + 'admin', + '{noop}Admin000', + 1, + now(), + now()); + +insert into participant_role (partic_id, role_id) +values (1, 4); + +select setval('participant_id_seq', (select max(id) from participant)); +select setval('user_id_seq', (select max(id) from "user")); diff --git a/server/src/main/resources/db/test-data/V00070__Create__defence_table.sql b/server/src/main/resources/db/test-data/V00070__Create__defence_table.sql index 30e30e1..c0bb458 100644 --- a/server/src/main/resources/db/test-data/V00070__Create__defence_table.sql +++ b/server/src/main/resources/db/test-data/V00070__Create__defence_table.sql @@ -1,4 +1,4 @@ -create table defence +create table defense ( id bigserial primary key, defence_date timestamptz, @@ -8,5 +8,5 @@ create table defence ); -- COMMENTS -comment on table defence is 'Таблица для хранения данных о защитах'; -comment on column defence.defence_date is 'Дата защиты'; \ No newline at end of file +comment on table defense is 'Таблица для хранения данных о защитах'; +comment on column defense.defence_date is 'Дата защиты'; \ No newline at end of file diff --git a/server/src/main/resources/db/test-data/group.sql b/server/src/main/resources/db/test-data/group.sql index 2f9f72d..0b0c6a4 100644 --- a/server/src/main/resources/db/test-data/group.sql +++ b/server/src/main/resources/db/test-data/group.sql @@ -1,3 +1,3 @@ -INSERT INTO "group" (name, principal_user_id) -VALUES ('ИВТ-1', 40), - ('ИВТ-2', 40); +INSERT INTO "group" (name, curator_teacher_id, created_at, updated_at) +VALUES ('ИВТ-1', 40, now(), now()), + ('ИВТ-2', 40, now(), now()); diff --git a/server/src/main/resources/db/test-data/student.sql b/server/src/main/resources/db/test-data/student.sql index d610ec4..840c618 100644 --- a/server/src/main/resources/db/test-data/student.sql +++ b/server/src/main/resources/db/test-data/student.sql @@ -1,7 +1,7 @@ do $$ begin - INSERT INTO student (form, + INSERT INTO studentData (form, protection_order, magistracy, digital_format_present, diff --git a/web/package-lock.json b/web/package-lock.json index b71c94c..346a708 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/webpack": "^5.28.5", + "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", "html-webpack-plugin": "^5.6.2", "style-loader": "^4.0.0", @@ -3128,6 +3129,100 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", + "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/core-js-compat": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", @@ -6215,6 +6310,51 @@ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/web/package.json b/web/package.json index 63c358b..bc379ea 100644 --- a/web/package.json +++ b/web/package.json @@ -31,6 +31,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/webpack": "^5.28.5", + "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", "html-webpack-plugin": "^5.6.2", "style-loader": "^4.0.0", diff --git a/web/pom.xml b/web/pom.xml index 0e9b08e..1ae56ab 100644 --- a/web/pom.xml +++ b/web/pom.xml @@ -9,7 +9,6 @@ 0.0.1 - ru.mskobaro.tdms web 0.0.1 pom diff --git a/web/src/Application.tsx b/web/src/Application.tsx index a1fef20..30bc287 100644 --- a/web/src/Application.tsx +++ b/web/src/Application.tsx @@ -5,17 +5,21 @@ import React from "react"; import 'bootstrap/dist/css/bootstrap.min.css'; import './index.css' import {RootStoreContext} from './utils/context'; -import {Home} from "./components/custom/layout/Home"; -import {UserProfilePage} from "./components/user/UserProfilePage"; -import {UserListPage} from "./components/user/UserListPage"; +import {Home} from "./components/layout/Home"; import {GroupListPage} from "./components/group/GroupListPage"; -import {Error} from "./components/custom/layout/Error"; +import {Error} from "./components/layout/Error"; +import {ParticipantListPage} from "./components/participant/ParticipantListPage"; +import {DefenceListPage} from "./components/defence/DefenceListPage"; +import {PreparationDirectionListPage} from "./components/dictionary/PreparationDirectionList"; +import {DiplomaTopicListPage} from "./components/dictionary/DiplomaTopicList"; const viewMap: ViewMap = { home: , - profile: , - userList: , + participantList: , groupList: , + defenceList: , + themeList: , + preparationDirectionList: , error: , } diff --git a/web/src/components/NotificationContainer.tsx b/web/src/components/NotificationContainer.tsx index fe8d3b4..91b96f5 100644 --- a/web/src/components/NotificationContainer.tsx +++ b/web/src/components/NotificationContainer.tsx @@ -1,9 +1,9 @@ import {ComponentContext} from "../utils/ComponentContext"; import {observer} from "mobx-react"; -import {Notification, NotificationType} from "../store/NotificationStore"; import {Card, CardBody, CardHeader, CardText, CardTitle} from "react-bootstrap"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {action, makeObservable} from "mobx"; +import {Notification, NotificationService, NotificationType} from "../services/NotificationService"; @observer export class NotificationContainer extends ComponentContext { @@ -15,10 +15,10 @@ export class NotificationContainer extends ComponentContext { render() { return
- {this.forEachNotificationRender(this.notificationStore.errors, NotificationType.ERROR)} - {this.forEachNotificationRender(this.notificationStore.successes, NotificationType.SUCCESS)} - {this.forEachNotificationRender(this.notificationStore.warnings, NotificationType.WARNING)} - {this.forEachNotificationRender(this.notificationStore.infos, NotificationType.INFO)} + {this.forEachNotificationRender(NotificationService.errors, NotificationType.ERROR)} + {this.forEachNotificationRender(NotificationService.successes, NotificationType.SUCCESS)} + {this.forEachNotificationRender(NotificationService.warnings, NotificationType.WARNING)} + {this.forEachNotificationRender(NotificationService.infos, NotificationType.INFO)}
} } @@ -33,7 +33,7 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t @action.bound close() { - this.notificationStore.close(this.props.notification.uuid); + NotificationService.close(this.props.notification.uuid); } get cardClassName() { diff --git a/web/src/components/controls/ReactiveControls.css b/web/src/components/controls/ReactiveControls.css new file mode 100644 index 0000000..43b8304 --- /dev/null +++ b/web/src/components/controls/ReactiveControls.css @@ -0,0 +1,25 @@ +.l-no-bg label::after { + background-color: rgba(0, 0, 0, 0) !important; +} + +.btn-group-lg > .btn, .btn-lg { + --bs-btn-border-radius: var(--bs-border-radius); +} + +.no-reaction { + transition: none !important; + background-color: rgba(255,255,255,0) !important; + color: inherit !important; + border: 0 !important; + box-shadow: none !important; +} + +.no-reaction:hover, +.no-reaction:focus, +.no-reaction:active { + transition: none !important; + background-color: rgba(255,255,255,0) !important; + color: inherit !important; + border: 0 !important; + box-shadow: none !important; +} \ No newline at end of file diff --git a/web/src/components/controls/ReactiveControls.tsx b/web/src/components/controls/ReactiveControls.tsx new file mode 100644 index 0000000..f809c78 --- /dev/null +++ b/web/src/components/controls/ReactiveControls.tsx @@ -0,0 +1,451 @@ +import React, { + ChangeEvent, + Component +} from "react"; +import { + ReactiveValue +} from "../../utils/reactive/reactiveValue"; +import { + observer +} from "mobx-react"; +import { + action, + makeObservable, + observable, + runInAction +} from "mobx"; +import { + Badge, + Button, + ButtonGroup, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, + FloatingLabel, + FormCheck, + FormControl, + FormSelect, + FormText +} from "react-bootstrap"; +import { + FontAwesomeIcon +} from "@fortawesome/react-fontawesome"; +import './ReactiveControls.css'; +import { + TableDescriptor +} from "../../utils/tables"; +import { + DataTable +} from "../data-tables/DataTable"; +import { + ModalState +} from "../../utils/modalState"; + +export interface ReactiveInputProps { + value: ReactiveValue; + label?: string; + disabled?: boolean; + className?: string; + validateless?: boolean; +} + +/* Boolean Input */ + +@observer +export class BooleanInput extends Component> { + constructor(props: any) { + super(props); + makeObservable(this); + if (this.props.value.value === undefined) { + this.props.value.setAuto(false); + } + this.props.value.setField(this.props.label); + } + + @action.bound + onChange(event: React.ChangeEvent) { + this.props.value.set(event.currentTarget.checked); + } + + render() { + return
+
+ {this.props.label} + +
+ +
+ } +} + +/* String Input */ + +@observer +export class StringInput extends Component> { + constructor(props: any) { + super(props); + makeObservable(this); + if (this.props.value.value === undefined) { + this.props.value.setAuto(''); + } + this.props.value.setField(this.props.label); + } + + @action.bound + onChange(event: React.ChangeEvent) { + this.props.value.set(event.currentTarget.value); + } + + render() { + const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; + + return
+ + + + +
+ } +} + +/* Password Input */ + +@observer +export class PasswordInput extends Component> { + @observable showPassword = false; + + constructor(props: any) { + super(props); + makeObservable(this); + if (this.props.value.value === undefined) { + this.props.value.setAuto(''); + } + this.props.value.setField(this.props.label); + } + + @action.bound + onChange(event: React.ChangeEvent) { + this.props.value.set(event.currentTarget.value); + } + + @action.bound + toggleShowPassword() { + this.showPassword = !this.showPassword; + } + + render() { + return
+
+ + + + +
+ +
+ } +} + +/* Select Input */ + +export interface SelectInputValue { + value: string; + label: string; +} + +export interface SelectInputProps extends ReactiveInputProps { + possibleValues: SelectInputValue[]; + unselectedLabel?: string; + initial?: SelectInputValue; +} + +@observer +export class SelectInput extends Component { + constructor(props: SelectInputProps) { + super(props); + makeObservable(this); + runInAction(() => { + this.options = props.possibleValues; + this.value = props.value; + this.value.setField(this.props.label); + + if (this.value.value === undefined && this.props.unselectedLabel !== undefined) { + this.options.unshift({ + value: '__unselected__', + label: this.props.unselectedLabel + }); + this.value.setAuto(this.options[0]); + } else if (this.props.initial) { + this.value.set(this.props.initial); + } else { + this.value.set(this.options[0]); + } + }); + } + + @observable options: { + value: string, + label: string + }[]; + @observable value: ReactiveValue; + + @action.bound + onChange(event: ChangeEvent) { + this.value.set(this.options.find(option => option.value === event.currentTarget.value)); + } + + render() { + const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; + + return
+ { + + + { + this.options.map(option => + ) + } + + + } + +
+ } +} + +/* Multiple select */ + +export interface MultipleSelectInputProps extends ReactiveInputProps { + possibleValues: SelectInputValue[]; + singleSelect?: boolean; + filtering?: boolean; +} + +@observer +export class DropdownSelectInput extends Component { + constructor(props: MultipleSelectInputProps) { + super(props); + makeObservable(this); + if (this.props.value.value === undefined) { + this.props.value.setAuto([]); + } + this.initField(this.props); + runInAction(() => { + this.options = props.possibleValues; + }); + } + + componentDidUpdate(prevProps: Readonly) { + if (this.value != prevProps.value) { + this.initField(this.props); + } + } + + @action.bound + initField(prps: MultipleSelectInputProps) { + this.value = prps.value; + this.value.setField(prps.label); + } + + @observable options: SelectInputValue[]; + @observable value: ReactiveValue; + @observable filter: string = ''; + + @action.bound + onCloseClick(event: React.MouseEvent) { + event.stopPropagation(); + const value = (event.target as HTMLElement).closest('span').getAttribute('data-value'); + this.value.set(this.value.value.filter(sel => sel.value !== value)); + } + + @action.bound + onAddClick(event: React.MouseEvent) { + const value = (event.target as HTMLElement).getAttribute('data-value'); + const option = this.options.find(opt => opt.value === value); + if (option) { + let contains = this.value.value.find(sel => sel.value === value); + if (contains) { + this.value.set(this.value.value.filter(sel => sel.value !== value)); + } else { + if (this.props.singleSelect) { + this.value.set([option]); + } else { + this.value.set([...this.value.value, option]); + } + } + } + } + + @action.bound + onFilterChange(event: React.ChangeEvent) { + this.filter = (event.target as HTMLInputElement).value; + if (this.filter === '') { + this.options = this.props.possibleValues; + } else { + this.options = this.props.possibleValues.filter(opt => opt.label.toLowerCase().includes(this.filter.toLowerCase())); + } + } + + render() { + const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; + const inputDisabledBackgroundStyle = this.props.disabled ? {backgroundColor: 'rgb(233, 236, 239)'} : {}; + const inputDisabledColor = this.props.disabled ? '#000000' : 'rgb(33, 37, 41)'; + + return
+ { + + + + { + !this.props.disabled && + + } + + + + { + this.props.filtering && +
+ +
+ } + { + this.options?.map(option => + + {option.label} + ) + } +
+
+ } + +
+ } +} + +/* Table */ + +export class TableInputNewEntryState { + constructor() { + makeObservable(this); + } + + @observable newEntryCallback: (entry: T) => void; + + @action.bound + newEntry(entry: T) { + this.newEntryCallback && this.newEntryCallback(entry); + } +} + +export interface TableInputProps { + table: TableDescriptor; + searchNewEntryState: TableInputNewEntryState; + searchNewEntryModalState: ModalState; + disabled?: boolean; +} + +@observer +export class TableInput extends Component> { + @observable table: TableDescriptor = this.props.table; + @observable searchNewEntryState: TableInputNewEntryState = this.props.searchNewEntryState; + @observable searchNewEntryModalState: ModalState = this.props.searchNewEntryModalState; + @observable disabled: boolean = this.props.disabled; + + constructor(props: any) { + super(props); + makeObservable(this); + this.table.pageable = false; + this.searchNewEntryState.newEntryCallback = this.addNewEntry; + } + + @action.bound + addNewEntry(entry: T) { + this.table.data.push(entry); + } + + componentDidUpdate() { + runInAction(() => { + this.table = this.props.table; + this.searchNewEntryState = this.props.searchNewEntryState; + this.searchNewEntryModalState = this.props.searchNewEntryModalState; + this.disabled = this.props.disabled; + }); + } + + render() { + return + } +} diff --git a/web/src/components/custom/controls/ReactiveControls.css b/web/src/components/custom/controls/ReactiveControls.css deleted file mode 100644 index 9efa40b..0000000 --- a/web/src/components/custom/controls/ReactiveControls.css +++ /dev/null @@ -1,3 +0,0 @@ -.l-no-bg label::after { - background-color: rgba(0, 0, 0, 0) !important; -} \ No newline at end of file diff --git a/web/src/components/custom/controls/ReactiveControls.tsx b/web/src/components/custom/controls/ReactiveControls.tsx deleted file mode 100644 index c604766..0000000 --- a/web/src/components/custom/controls/ReactiveControls.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, {ChangeEvent, Component} from "react"; -import {ReactiveValue} from "../../../utils/reactive/reactiveValue"; -import {observer} from "mobx-react"; -import {action, makeObservable, observable, runInAction} from "mobx"; -import {Button, FloatingLabel, FormControl, FormSelect, FormText} from "react-bootstrap"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import './ReactiveControls.css'; - -export interface ReactiveInputProps { - value: ReactiveValue; - label?: string; - disabled?: boolean; - className?: string; - validateless?: boolean; -} - -@observer -export class StringInput extends Component> { - constructor(props: any) { - super(props); - makeObservable(this); - if (this.props.value.value === undefined) { - this.props.value.setAuto(''); - } - this.props.value.setField(this.props.label); - } - - @action.bound - onChange(event: React.ChangeEvent) { - this.props.value.set(event.currentTarget.value); - } - - render() { - const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; - - return
- - - - -
- } -} - -@observer -export class PasswordInput extends Component> { - @observable showPassword = false; - - constructor(props: any) { - super(props); - makeObservable(this); - if (this.props.value.value === undefined) { - this.props.value.setAuto(''); - } - this.props.value.setField(this.props.label); - } - - @action.bound - onChange(event: React.ChangeEvent) { - this.props.value.set(event.currentTarget.value); - } - - @action.bound - toggleShowPassword() { - this.showPassword = !this.showPassword; - } - - render() { - return
-
- - - - -
- -
- } -} - -export interface ReactiveSelectInputSelect { - value: string; - label: string; -} - -export interface ReactiveSelectInputProps extends ReactiveInputProps { - possibleValues: ReactiveSelectInputSelect[]; - unselectedLabel?: string; - initial?: ReactiveSelectInputSelect; -} - -@observer -export class SelectInput extends Component { - constructor(props: ReactiveSelectInputProps) { - super(props); - makeObservable(this); - runInAction(() => { - this.options = props.possibleValues; - this.value = props.value; - this.value.setField(this.props.label); - - if (this.value.value === undefined && this.props.unselectedLabel !== undefined) { - this.options.unshift({value: '__unselected__', label: this.props.unselectedLabel}); - this.value.setAuto(this.options[0]); - } else if (this.props.initial) { - this.value.set(this.props.initial); - } else { - this.value.set(this.options[0]); - } - }); - } - - @observable options: { value: string, label: string }[]; - @observable value: ReactiveValue; - - @action.bound - onChange(event: ChangeEvent) { - this.value.set(this.options.find(option => option.value === event.currentTarget.value)); - } - - render() { - const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; - - return
- { - - - { - this.options.map(option => - ) - } - - - } - -
- } -} \ No newline at end of file diff --git a/web/src/components/custom/layout/Header.tsx b/web/src/components/custom/layout/Header.tsx deleted file mode 100644 index b720f97..0000000 --- a/web/src/components/custom/layout/Header.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap"; -import {RouterLink} from "mobx-state-router"; -import {IAuthenticated} from "../../../models/user"; -import {observer} from "mobx-react"; -import {post} from "../../../utils/request"; -import {UserLoginModal} from "../../user/UserLoginModal"; -import {ModalState} from "../../../utils/modalState"; -import {action, makeObservable, observable} from "mobx"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {ComponentContext} from "../../../utils/ComponentContext"; -import {AddGroupModal} from "../../group/AddGroupModal"; -import {UserRegistrationModal} from "../../user/UserRegistrationModal"; - -@observer -export class Header extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - @observable loginModalState = new ModalState(); - @observable addGroupModalState = new ModalState(); - @observable userRegistrationModalState = new ModalState(); - - render() { - let userThink = this.thinkStore.isThinking('updateCurrentUser'); - - return <> -
- - - - TDMS - - - - - -
- - - - - } -} - -@observer -class AuthenticatedItems extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - @action.bound - logout() { - post('user/logout').then(() => { - this.userStore.updateCurrentUser(); - this.routerStore.goTo('home').then(); - this.notificationStore.success('Вы успешно вышли из системы', 'Выход'); - }); - } - - render() { - return <> - Пользователь: - - Моя страница - - Выйти - - - } -} diff --git a/web/src/components/custom/layout/Home.tsx b/web/src/components/custom/layout/Home.tsx deleted file mode 100644 index 1f4e41b..0000000 --- a/web/src/components/custom/layout/Home.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import {Page} from "./Page"; -import {observer} from "mobx-react"; - -@observer -export class Home extends Page { - get page() { - return

Home

- } -} diff --git a/web/src/components/custom/DataTable.css b/web/src/components/data-tables/DataTable.css similarity index 100% rename from web/src/components/custom/DataTable.css rename to web/src/components/data-tables/DataTable.css diff --git a/web/src/components/custom/DataTable.tsx b/web/src/components/data-tables/DataTable.tsx similarity index 76% rename from web/src/components/custom/DataTable.tsx rename to web/src/components/data-tables/DataTable.tsx index bc54750..d29f4e5 100644 --- a/web/src/components/custom/DataTable.tsx +++ b/web/src/components/data-tables/DataTable.tsx @@ -4,7 +4,7 @@ import {observer} from "mobx-react"; import {action, computed, makeObservable, observable, runInAction} from "mobx"; import {Button, ButtonGroup, FormSelect, FormText, Table} from "react-bootstrap"; import _ from "lodash"; -import {ChangeEvent} from "react"; +import React, {ChangeEvent} from "react"; import {ModalState} from "../../utils/modalState"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import './DataTable.css'; @@ -14,10 +14,13 @@ export interface DataTableProps { filterModalState?: ModalState; name?: string; headless?: boolean; + editable?: boolean; + editableModalState?: ModalState; + additionalButtons?: React.ReactNode; } @observer -export class DataTable extends ComponentContext> { +export class DataTable extends ComponentContext & { className?: string }> { constructor(props: DataTableProps) { super(props); makeObservable(this); @@ -27,6 +30,23 @@ export class DataTable extends ComponentContext> { @observable name = this.props.name; @observable headless = this.props.headless; @observable filterModalState = this.props.filterModalState; + @observable editable = this.props.editable; + @observable editableModalState = this.props.editableModalState; + @observable className = this.props.className; + @observable additionalButtons?: React.ReactNode = this.props.additionalButtons; + + componentDidUpdate() { + runInAction(() => { + this.descriptor = this.props.tableDescriptor; + this.name = this.props.name; + this.headless = this.props.headless; + this.filterModalState = this.props.filterModalState; + this.editable = this.props.editable; + this.editableModalState = this.props.editableModalState; + this.className = this.props.className; + this.additionalButtons = this.props.additionalButtons; + }); + } @computed get isFirstPage() { @@ -85,7 +105,7 @@ export class DataTable extends ComponentContext> { borderBottomRightRadius: this.descriptor.pageable ? '0' : '0.25rem', } - return <> + return
{ !this.headless && this.header @@ -104,7 +124,7 @@ export class DataTable extends ComponentContext> { this.descriptor.pageable && this.footer } - +
} @computed @@ -120,16 +140,21 @@ export class DataTable extends ComponentContext> { return
{this.name} - +
{ - this.descriptor.pageable && -
{`Записей на странице: ${this.descriptor.pageSize} `}
+ this.additionalButtons && + this.additionalButtons } -
+ { - <> - {`Всего записей: ${this.filteredData.length}`} - + this.descriptor.pageable && +
{`Записей на странице: ${this.descriptor.pageSize} `}
+ } +
+ { + <> + {`Всего записей: ${this.filteredData.length}`} + { this.descriptor.pageable && <> @@ -137,20 +162,21 @@ export class DataTable extends ComponentContext> { } - { - this.filterModalState && -
- -
- } - + { + this.filterModalState && +
+ +
+ } + - } -
-
+ } +
+ +
} @@ -168,7 +194,7 @@ export class DataTable extends ComponentContext> { borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)', }; - return + return
runInAction(() => { const other = this.descriptor.columns @@ -215,13 +241,13 @@ export class DataTable extends ComponentContext> { return 0; } - return this.filteredData + let elements = this.filteredData .sort(masterComparator) .slice(this.descriptor.page * this.descriptor.pageSize, (this.descriptor.page + 1) * this.descriptor.pageSize) .map((row, i) => { const rowAny = row as any; const lastRow = i === this.descriptor.pageSize - 1; - return + return { this.descriptor.columns.map(column => { const firstColumn = column === this.descriptor.columns[0]; @@ -230,9 +256,9 @@ export class DataTable extends ComponentContext> { const style = { borderLeft: firstColumn ? 'none' : '1px solid var(--bs-table-border-color)', borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)', - borderBottom: lastRow ? 'none' : '1px solid var(--bs-table-border-color)', + borderBottom: lastRow && !this.editable ? 'none' : '1px solid var(--bs-table-border-color)', } - return {column.format(rowAny[column.key], row)} { @@ -244,6 +270,22 @@ export class DataTable extends ComponentContext> { } }); + + if (this.editable) { + elements.push( + + + + ); + } + + return elements; } @computed diff --git a/web/src/components/defence/DefenceListPage.tsx b/web/src/components/defence/DefenceListPage.tsx new file mode 100644 index 0000000..3a0a0a6 --- /dev/null +++ b/web/src/components/defence/DefenceListPage.tsx @@ -0,0 +1,74 @@ +import {observer} from "mobx-react"; +import {Page} from "../layout/Page"; +import {action, makeObservable, observable, reaction, runInAction} from "mobx"; +import {get} from "../../utils/request"; +import {Group} from "../../models/group"; +import React from "react"; +import {ThinkService} from "../../services/ThinkService"; +import {DataTable} from "../data-tables/DataTable"; +import {Column, TableDescriptor} from "../../utils/tables"; +import {Defence} from "../../models/defence"; +import {Participant} from "../../models/participant"; +import {datetimeConverter} from "../../utils/converters"; +import {StudentData} from "../../models/studentData"; + +class DefenceListPageState { + constructor() { + makeObservable(this); + this.updateDefences(); + reaction(() => this.defences, () => { + this.tableDescriptor = new TableDescriptor([ + new Column('commissionMembers', 'ГЭК', p => p ? p.length : 0), + new Column('groups', 'Группы', g => g && g.length ? g.map(g => g.name).join(', ') : 'Пусто'), + new Column('groups', 'Студентов', g => { + if (!g) return 'Пусто'; + const students: StudentData[] = []; + g.forEach(group => group.students.forEach(student => students.push(student))); + return students.length; + }), + new Column('createdAt', 'Дата создания', datetimeConverter), + new Column('updatedAt', 'Дата обновления', datetimeConverter), + ], this.defences, true + ); + }); + } + + @observable defences: Defence[] = []; + @observable tableDescriptor: TableDescriptor; + + @action.bound + updateDefences() { + ThinkService.think(); + get('/defence/all').then((defences) => { + runInAction(() => { + this.defences = defences; + }); + }).finally(() => { + ThinkService.completeAll(); + }); + } +} + +@observer +export class DefenceListPage extends Page { + constructor(props: any) { + super(props); + makeObservable(this); + this.fields = new DefenceListPageState(); + } + + @observable fields: DefenceListPageState; + + get page() { + return <> + { + <> + { + this.fields.tableDescriptor && + + } + + } + + } +} diff --git a/web/src/components/dictionary/DiplomaTopicList.tsx b/web/src/components/dictionary/DiplomaTopicList.tsx new file mode 100644 index 0000000..3703d08 --- /dev/null +++ b/web/src/components/dictionary/DiplomaTopicList.tsx @@ -0,0 +1,118 @@ +import {observer} from "mobx-react"; +import {Page} from "../layout/Page"; +import {action, makeObservable, observable, reaction, runInAction} from "mobx"; +import React from "react"; +import {DataTable} from "../data-tables/DataTable"; +import {Column, TableDescriptor} from "../../utils/tables"; +import {PreparationDirection} from "../../models/preparationDirection"; +import {get} from "../../utils/request"; +import {datetimeConverter} from "../../utils/converters"; +import {Button} from "react-bootstrap"; +import {ModalState} from "../../utils/modalState"; +import {UserService} from "../../services/UserService"; +import {DiplomaTopic} from "../../models/diplomaTopic"; +import {TeacherData} from "../../models/teacherData"; +import {fullName} from "../../models/participant"; +import DiplomaTopicModal from "./DiplomaTopicModal"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import _ from "lodash"; + +const getAllDiplomaTopics = () => { + return get("diploma-topic/all"); +} + +class DiplomaTopicPageState { + constructor() { + makeObservable(this); + this.updateDiplomaTopics(); + reaction(() => this.diplomaTopics, () => { + this.tableDescriptor = new TableDescriptor([ + new Column('name', 'Название', undefined, (dp) => { + return ; + }), + new Column('teacher', 'Преподаватель', (value) => { + return value ? fullName(value.participant) : "Не указан"; + }), + new Column('preparationDirection', 'Код направления подготовки', value => { + return value ? value.code : "Не указано"; + }), + new Column('preparationDirection', 'Название Направления подготовки', value => { + return value ? value.name : "Не указано"; + }), + new Column('createdAt', 'Дата создания', datetimeConverter), + new Column('updatedAt', 'Дата обновления', datetimeConverter), + ], this.diplomaTopics, true + ); + }, {fireImmediately: true}); + } + + @observable diplomaTopics: DiplomaTopic[] = []; + @observable selectedTopic: DiplomaTopic = undefined; + @observable tableDescriptor: TableDescriptor; + @observable diplomaTopicModalState = new ModalState(false, this.updateDiplomaTopics); + @observable diplomaTopicEditModalState = new ModalState(false, this.updateDiplomaTopics); + + @action.bound + updateDiplomaTopics() { + getAllDiplomaTopics().then(dt => { + runInAction(() => { + this.diplomaTopics = dt; + }) + }); + } + + @action.bound + onIconClicked(event: React.MouseEvent) { + const pd = this.diplomaTopics.find(pd => pd.id === _.toNumber(event.currentTarget.getAttribute('data-value'))); + if (pd) { + this.selectedTopic = pd; + this.diplomaTopicEditModalState.open(); + } + } +} + +@observer +export class DiplomaTopicListPage extends Page { + constructor(props: any) { + super(props); + makeObservable(this); + this.fields = new DiplomaTopicPageState(); + } + + @observable fields: DiplomaTopicPageState; + + get page() { + return <> + { + <> + { + this.fields.tableDescriptor && + } + /> + } + + { + this.fields.selectedTopic && + + } + + } + + } +} + +const AdditionalButtons = observer(({state}: {state: DiplomaTopicPageState}) => { + return ( +
+ {(UserService.isSecretary || UserService.isAdministrator) && ( + + )} +
+ ); +}); + + diff --git a/web/src/components/dictionary/DiplomaTopicModal.tsx b/web/src/components/dictionary/DiplomaTopicModal.tsx new file mode 100644 index 0000000..18d7366 --- /dev/null +++ b/web/src/components/dictionary/DiplomaTopicModal.tsx @@ -0,0 +1,180 @@ +import {action, makeObservable, observable, runInAction} from "mobx"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {ModalState} from "../../utils/modalState"; +import {observer} from "mobx-react"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; +import {DropdownSelectInput, SelectInputValue, StringInput} from "../controls/ReactiveControls"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {NotificationService} from "../../services/NotificationService"; +import {get, post} from "../../utils/request"; +import {Group} from "../../models/group"; +import {required, strLength, strPattern} from "../../utils/reactive/validators"; +import _ from "lodash"; +import {fullName, Participant} from "../../models/participant"; +import {datetimeConverter} from "../../utils/converters"; +import {StudentData} from "../../models/studentData"; +import {PreparationDirection} from "../../models/preparationDirection"; +import {DiplomaTopic} from "../../models/diplomaTopic"; +import {TeacherData} from "../../models/teacherData"; + +const getAllTeachers = () => { + return get("teacher-data/get-all"); +} + +const getAllPreparationDirections = () => { + return get("prep-direction/get-all"); +} + +class DiplomaTopicModalState { + constructor(modalState: ModalState, topic: DiplomaTopic) { + makeObservable(this); + this.modalState = modalState; + getAllTeachers().then(teachers => { + runInAction(() => { + this.allTeachers = teachers; + }); + }); + getAllPreparationDirections().then(preparationDirections => { + runInAction(() => { + this.allPreparationDirections = preparationDirections; + }); + }); + + if (topic) { + this.editMode = false; + this.adding = false; + this.name.setAuto(topic.name); + this.teacher.setAuto([{ + value: _.toString(topic.id), + label: topic.name, + }]); + this.preparationDirection.setAuto([{ + value: _.toString(topic.preparationDirection.id), + label: topic.preparationDirection.name, + }]); + this.topicId = topic.id; + } + } + + @observable modalState: ModalState; + @observable editMode = true; + @observable adding = true; + @observable topicId: number = undefined; + + @observable allTeachers: TeacherData[] = []; + @observable allPreparationDirections: PreparationDirection[] = []; + + @observable name = new ReactiveValue().setAuto("").addValidator(required); + @observable teacher = new ReactiveValue(); + @observable preparationDirection = new ReactiveValue(); + + anyInvalid(): boolean { + let invalid = this.name.invalid + || this.teacher.invalid + || this.preparationDirection.invalid; + + if (this.adding) { + invalid = invalid || !this.name.touched + } + + return invalid; + } + + touchAll() { + this.name.touch(); + this.teacher.touch(); + this.preparationDirection.touch(); + } + + @action.bound + create() { + if (this.anyInvalid()) { + this.touchAll(); + NotificationService.error("Пожалуйста, исправьте ошибки на форме"); + return; + } + + post("diploma-topic/save", { + id: this.topicId, + name: this.name.value, + teacher: this.teacher.value && this.teacher.value[0] + ? this.getTeacherById(_.toNumber(this.teacher.value[0].value)) + : undefined, + preparationDirection: this.preparationDirection.value && this.preparationDirection.value[0] + ? this.getPreparationDirectionById(_.toNumber(this.preparationDirection.value[0].value)) + : undefined, + }).then(() => { + if (this.adding) + NotificationService.success(`Тема ВКР ${this.name.value} успешно добавлена`); + else + NotificationService.success(`Тема ВКР ${this.name.value} успешно изменена`); + if (this.modalState) this.modalState.onApply(); + }); + + this.modalState.close(); + } + + getTeacherById(id: number): TeacherData | undefined { + return this.allTeachers.find(teacher => teacher.id === id); + } + + getPreparationDirectionById(id: number): PreparationDirection | undefined { + return this.allPreparationDirections.find(direction => direction.id === id); + } + + @action.bound + changeEditMode() { + this.editMode = !this.editMode; + } +} + +@observer +export default class DiplomaTopicModal extends ComponentContext<{ modalState: ModalState, topic?: DiplomaTopic}> { + constructor(props: any) { + super(props); + makeObservable(this); + runInAction(() => { + this.fields = new DiplomaTopicModalState(props.modalState, props.topic); + }); + } + + @observable fields: DiplomaTopicModalState; + + render() { + return ( + + + { + this.fields.editMode + ? "Редактирование темы ВКР" + : "Новая тема ВКР" + } + + + + { + return {label: fullName(value.participant), value: value.id?.toString() || ""}; + })} + value={this.fields.teacher} disabled={!this.fields.editMode}/> + { + return {label: value.name, value: value.id?.toString() || ""}; + })} + value={this.fields.preparationDirection} disabled={!this.fields.editMode}/> + + + { + !this.fields.editMode && + + } + { + this.fields.editMode && + + } + + + + ); + } +} \ No newline at end of file diff --git a/web/src/components/dictionary/PreparationDirectionList.tsx b/web/src/components/dictionary/PreparationDirectionList.tsx new file mode 100644 index 0000000..61cbf3b --- /dev/null +++ b/web/src/components/dictionary/PreparationDirectionList.tsx @@ -0,0 +1,101 @@ +import {observer} from "mobx-react"; +import {Page} from "../layout/Page"; +import {action, makeObservable, observable, reaction, runInAction} from "mobx"; +import React from "react"; +import {DataTable} from "../data-tables/DataTable"; +import {Column, TableDescriptor} from "../../utils/tables"; +import {PreparationDirection} from "../../models/preparationDirection"; +import {get} from "../../utils/request"; +import {datetimeConverter} from "../../utils/converters"; +import {Button} from "react-bootstrap"; +import {ModalState} from "../../utils/modalState"; +import PreparationDirectionModal from "./PreparationDirectionModal"; +import {UserService} from "../../services/UserService"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import _ from "lodash"; + +const getAllPreparationDirections = () => { + return get("prep-direction/get-all"); +} + +class PreparationDirectionPageState { + constructor() { + makeObservable(this); + getAllPreparationDirections().then(pd => { + runInAction(() => { + this.preparationDirections = pd; + }) + }); + + reaction(() => this.preparationDirections, () => { + this.tableDescriptor = new TableDescriptor([ + new Column('code', 'Код', undefined, (pd) => { + return ; + }), + new Column('name', 'Название'), + new Column('createdAt', 'Дата создания', datetimeConverter), + new Column('updatedAt', 'Дата обновления', datetimeConverter), + ], this.preparationDirections, true + ); + }, {fireImmediately: true}); + } + + @observable preparationDirections: PreparationDirection[] = []; + @observable tableDescriptor: TableDescriptor; + @observable preparationDirectionModalState = new ModalState(); + @observable preparationDirectionEditModalState = new ModalState(); + @observable selectedDirection: PreparationDirection; + + @action.bound + onIconClicked(event: React.MouseEvent) { + const pd = this.preparationDirections.find(pd => pd.id === _.toNumber(event.currentTarget.getAttribute('data-value'))); + if (pd) { + this.selectedDirection = pd; + this.preparationDirectionEditModalState.open(); + } + } +} + +@observer +export class PreparationDirectionListPage extends Page { + constructor(props: any) { + super(props); + makeObservable(this); + this.fields = new PreparationDirectionPageState(); + } + + @observable fields: PreparationDirectionPageState; + + get page() { + return <> + { + <> + { + this.fields.tableDescriptor && + }/> + } + + { + this.fields.selectedDirection && + + } + + } + + } +} + +const AdditionalButtons = observer(({state}: {state: PreparationDirectionPageState}) => { + return ( +
+ {(UserService.isSecretary || UserService.isAdministrator) && ( + + )} +
+ ); +}); + diff --git a/web/src/components/dictionary/PreparationDirectionModal.tsx b/web/src/components/dictionary/PreparationDirectionModal.tsx new file mode 100644 index 0000000..d4a1d80 --- /dev/null +++ b/web/src/components/dictionary/PreparationDirectionModal.tsx @@ -0,0 +1,140 @@ +import {action, makeObservable, observable, runInAction} from "mobx"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {ModalState} from "../../utils/modalState"; +import {observer} from "mobx-react"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; +import {DropdownSelectInput, SelectInputValue, StringInput} from "../controls/ReactiveControls"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {NotificationService} from "../../services/NotificationService"; +import {get, post} from "../../utils/request"; +import {Group} from "../../models/group"; +import {required, strLength, strPattern} from "../../utils/reactive/validators"; +import _ from "lodash"; +import {fullName, Participant} from "../../models/participant"; +import {datetimeConverter} from "../../utils/converters"; +import {StudentData} from "../../models/studentData"; +import {PreparationDirection} from "../../models/preparationDirection"; + +class PreparationDirectionModalState { + constructor(modalState: ModalState, direction?: PreparationDirection) { + makeObservable(this); + this.modalState = modalState; + if (direction) { + console.log('PreparationDirectionModalState: initializing with direction', direction); + this.name.setAuto(direction.name); + this.code.setAuto(direction.code); + this.id = direction.id; + this.editMode = true; + } else { + this.name.setAuto(""); + this.code.setAuto(""); + this.viewMode = false; + } + } + + @observable modalState: ModalState; + @observable name = new ReactiveValue().addValidator(required); + @observable code = new ReactiveValue().addValidator(required); + @observable editMode = false; + @observable viewMode = true; + @observable id: number = undefined; + + anyInvalid(): boolean { + let invalid = this.name.invalid || this.code.invalid; + if (!this.editMode) { + invalid = invalid || !this.name.touched || !this.code.touched; + } + return invalid; + } + + touchAll() { + this.name.touch(); + this.code.touch(); + } + + @action.bound + save() { + if (this.anyInvalid()) { + this.touchAll(); + NotificationService.error("Пожалуйста, исправьте ошибки на форме"); + return; + } + + post("prep-direction/save", { + id: this.id, + name: this.name.value, + code: this.code.value + }).then(() => { + if (this.editMode) { + NotificationService.success(`Направление подготовки ${this.code.value} успешно обновлено`); + } else { + NotificationService.success(`Направление подготовки ${this.code.value} успешно добавлено`); + } + }); + + this.modalState.close(); + if (this.editMode) { + this.toView(); + } + } + + @action.bound + toEdit() { + this.viewMode = false; + } + + @action.bound + toView() { + this.viewMode = true; + } +} + +@observer +export default class PreparationDirectionModal extends ComponentContext<{ modalState: ModalState, direction?: PreparationDirection }> { + constructor(props: any) { + super(props); + makeObservable(this); + runInAction(() => { + this.fields = new PreparationDirectionModalState(props.modalState, props.direction); + }); + } + + componentDidUpdate(prevProps: Readonly<{ modalState: ModalState; direction?: PreparationDirection }>) { + if (this.props.direction !== prevProps.direction) { + runInAction(() => { + this.fields = new PreparationDirectionModalState(this.props.modalState, this.props.direction); + }); + } + } + + @observable fields: PreparationDirectionModalState; + + render() { + return ( + + + { + this.fields.editMode + ? "Редактирование направления подготовки " + this.fields.code.value + : "Добавление направления подготовки" + } + + + + + + + { + !this.fields.viewMode && + + } + { + this.fields.editMode && this.fields.viewMode && + + } + + + + ); + } +} \ No newline at end of file diff --git a/web/src/components/group/AddGroupModal.tsx b/web/src/components/group/AddGroupModal.tsx deleted file mode 100644 index b83ce98..0000000 --- a/web/src/components/group/AddGroupModal.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {ComponentContext} from "../../utils/ComponentContext"; -import {observer} from "mobx-react"; -import {ModalState} from "../../utils/modalState"; -import {action, computed, makeObservable, observable} from "mobx"; -import {ReactiveValue} from "../../utils/reactive/reactiveValue"; -import {required, strLength, strPattern} from "../../utils/reactive/validators"; -import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; -import {StringInput} from "../custom/controls/ReactiveControls"; -import {post} from "../../utils/request"; - -export interface CreateGroupModalProps { - modalState: ModalState; -} - -@observer -export class AddGroupModal extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - @observable name = new ReactiveValue() - .addValidator(required) - .addValidator(strLength(3, 50)) - .addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-")); - - @action.bound - creationRequest() { - post('/group/create-group', {name: this.name.value}, false).then(() => { - this.notificationStore.success(`Группа ${this.name.value} создана`); - }).finally(() => { - this.props.modalState.close(); - }); - } - - @computed - get formInvalid() { - return this.name.invalid || !this.name.touched; - } - - render() { - return - - Добавление группы - - - - - - - - - - } -} \ No newline at end of file diff --git a/web/src/components/group/GroupListPage.tsx b/web/src/components/group/GroupListPage.tsx index 421811f..0a8036f 100644 --- a/web/src/components/group/GroupListPage.tsx +++ b/web/src/components/group/GroupListPage.tsx @@ -1,203 +1,92 @@ import {observer} from "mobx-react"; -import {Page} from "../custom/layout/Page"; -import {action, computed, makeObservable, observable, reaction, runInAction} from "mobx"; -import {Column, TableDescriptor} from "../../utils/tables"; +import {Page} from "../layout/Page"; +import {action, makeObservable, observable, reaction, runInAction} from "mobx"; import {get} from "../../utils/request"; -import {DataTable} from "../custom/DataTable"; -import {IGroup} from "../../models/IGroup"; -import {Component} from "react"; -import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; -import {StringInput} from "../custom/controls/ReactiveControls"; -import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {Group} from "../../models/group"; +import React from "react"; import {ModalState} from "../../utils/modalState"; +import {ThinkService} from "../../services/ThinkService"; +import {DataTable} from "../data-tables/DataTable"; +import {Column, TableDescriptor} from "../../utils/tables"; +import {datetimeConverter} from "../../utils/converters"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {required, strLength, strPattern} from "../../utils/reactive/validators"; +import _ from "lodash"; +import {StudentData} from "../../models/studentData"; +import GroupProfileModal from "./GroupProfileModal"; +import {GroupListFilterModal} from "./GroupTableFilterModal"; + +class GroupListPageState { + constructor() { + makeObservable(this); + this.updateGroups(); + reaction(() => this.groups, () => { + this.tableDescriptor = new TableDescriptor([ + new Column('name', 'Название', undefined, (group) => { + return ; + }), + new Column('students', 'Студентов', v => v ? v.length : 0), + new Column('createdAt', 'Дата создания', datetimeConverter), + new Column('updatedAt', 'Дата обновления', datetimeConverter), + ], this.groups, true, this.filters + ); + }, {fireImmediately: true}); + } + + @observable filterModalState = new ModalState(); + @observable groupModalState = new ModalState(); + @observable groups: Group[] = []; + @observable filters: ((group: Group) => boolean)[] = []; + + @observable selectedGroup: Group = null; + @observable tableDescriptor: TableDescriptor; + + @action.bound + updateGroups() { + ThinkService.think(); + get('/group/get-all-groups').then((groups) => { + runInAction(() => { + this.groups = groups; + }); + }).finally(() => { + ThinkService.completeAll(); + }); + } + + @action.bound + openModal(event: React.MouseEvent) { + const group = this.groups.find(g => g.id === _.toNumber(event.currentTarget.getAttribute('data-value'))); + if (group) { + this.selectedGroup = group; + this.groupModalState.open(); + } + } +} @observer export class GroupListPage extends Page { constructor(props: {}) { super(props); makeObservable(this); - reaction(() => this.groups, () => { - this.tableDescriptor = new TableDescriptor(this.groupColumns, this.groups); - }); + this.fields = new GroupListPageState(); } - componentDidMount() { - this.requestGroups(); - } - - componentDidUpdate() { - runInAction(() => { - this.isAdministrator = this.userStore.isAdministrator; - }); - } - - @observable filterModalState = new ModalState(); - @observable editModalState = new ModalState(); - @observable currentGroup: IGroup; - - @observable groups: IGroup[]; - @observable tableDescriptor: TableDescriptor; - @observable isAdministrator: boolean; - - groupColumns = [ - new Column('name', 'Название', x => x, (grp) => { - return this.isAdministrator && { - this.openEditModal(grp) - }} icon={'pen-to-square'}/> - } - ), - new Column('curatorName', 'Куратор', (value: string) => value ?? 'Не назначен'), - ]; - - @action.bound - requestGroups() { - this.thinkStore.think(); - get('/group/get-all-groups').then(groups => { - runInAction(() => { - this.groups = groups; - }); - }).finally(() => { - this.thinkStore.completeOne(); - }); - } - - @action.bound - openEditModal(group: IGroup) { - runInAction(() => { - this.currentGroup = group; - this.editModalState.open(); - }); - } + @observable fields: GroupListPageState; get page() { return <> { - this.tableDescriptor && <> - - { - this.currentGroup && - + this.fields.tableDescriptor && + } + { + this.fields.selectedGroup && + + } + } } } - -interface GroupListFilterProps { - modalState: ModalState; - filters: ((group: IGroup) => boolean)[]; -} - -@observer -class GroupListFilterModal extends Component { - constructor(props: GroupListFilterProps) { - super(props); - makeObservable(this); - - runInAction(() => { - this.filters.push(this.nameFilter); - this.filters.push(this.curatorFilter); - }); - } - - @observable filters = this.props.filters; - @observable modalState = this.props.modalState; - @observable nameField = new ReactiveValue().syncWithParam('name'); - @observable curatorField = new ReactiveValue().syncWithParam('curator'); - - @observable nameFilter = (group: IGroup) => { - if (!this.nameField.value) return true; - return group.name?.includes(this.nameField.value) - }; - - @observable curatorFilter = (group: IGroup) => { - if (!this.curatorField.value) return true; - return group.curatorName?.includes(this.curatorField.value) - }; - - @action.bound - reset() { - this.nameField.set(""); - this.curatorField.set(""); - } - - render() { - return - - Фильтр - - - - - - - - - - - } -} - -interface EditGroupModalProps { - modalState: ModalState; - group: IGroup -} - -@observer -class EditGroupModal extends ComponentContext { - constructor(props: EditGroupModalProps) { - super(props); - makeObservable(this); - } - - componentDidUpdate() { - runInAction(() => { - this.group = this.props.group; - this.nameField.setAuto(this.group.name); - this.curatorField.setAuto(this.group.curatorName ?? ''); - }); - } - - @observable group = this.props.group; - @observable modalState = this.props.modalState; - - @observable nameField = new ReactiveValue().setAuto(this.group.name) - .addValidator(required) - .addValidator(strLength(3, 50)) - .addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-")); - @observable curatorField = new ReactiveValue().setAuto(this.group.curatorName ?? ''); - - @action.bound - save() { - this.notificationStore.warn('Приносим извинения за неудобства', 'Сохранение не реализовано'); - this.modalState.close(); - } - - @computed - get formInvalid() { - return this.nameField.invalid || !this.nameField.touched - || this.curatorField.invalid || !this.curatorField.touched; - } - - render() { - return - - Редактирование группы {this.group.name} - - - - - - - - - - - } -} diff --git a/web/src/components/group/GroupProfileModal.tsx b/web/src/components/group/GroupProfileModal.tsx new file mode 100644 index 0000000..004991d --- /dev/null +++ b/web/src/components/group/GroupProfileModal.tsx @@ -0,0 +1,230 @@ +import {action, computed, makeObservable, observable, runInAction} from "mobx"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {ModalState} from "../../utils/modalState"; +import {observer} from "mobx-react"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; +import {DropdownSelectInput, SelectInputValue, StringInput} from "../controls/ReactiveControls"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {NotificationService} from "../../services/NotificationService"; +import {get, post} from "../../utils/request"; +import {Group} from "../../models/group"; +import {required, strLength, strPattern} from "../../utils/reactive/validators"; +import _ from "lodash"; +import {fullName} from "../../models/participant"; +import {datetimeConverter} from "../../utils/converters"; +import {StudentData} from "../../models/studentData"; +import {UserService} from "../../services/UserService"; +import PreparationDirectionModal from "../dictionary/PreparationDirectionModal"; +import {PreparationDirection} from "../../models/preparationDirection"; + +const mapStudent = (student: StudentData) => { + return {value: _.toString(student.id), label: fullName(student.participant)}; +} + +const mapDirOfPrep = (prep: PreparationDirection) => { + return {value: _.toString(prep.id), label: prep.code} +} + +const getAllDirOfPrep = () => { + return get("prep-direction/get-all"); +} + +class GroupProfileState { + constructor(props: GroupProfileModalProps) { + makeObservable(this); + this.modalState = props.modalState; + this.viewMode = !props.creation; + + this.group = props.group ?? {name: ""}; + this.groupName.setAuto(this.group.name); + this.createdAt.setAuto(datetimeConverter(this.group.createdAt)); + this.updatedAt.setAuto(datetimeConverter(this.group.updatedAt)); + this.students.setAuto(this.group.students?.map(mapStudent)); + if (this.group.preparationDirection) { + this.directionOfPreparation.setAuto([mapDirOfPrep(this.group.preparationDirection)]); + } + + get('student/all-without-group').then((students) => { + runInAction(() => { + this.allStudents = students; + }); + }); + + getAllDirOfPrep().then(dop => { + runInAction(() => { + this.allDirOfPrep = dop; + }); + }); + } + + @observable viewMode: boolean; + @observable editMode: boolean = false; + @observable group: Group; + @observable modalState: ModalState; + + @observable allDirOfPrep: PreparationDirection[] = []; + @observable allStudents: StudentData[] = []; + + @observable groupName = new ReactiveValue().addValidator(required).addValidator(strLength(3, 50)) + .addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-")); + @observable students = new ReactiveValue(); + @observable directionOfPreparation = new ReactiveValue().addValidator(required); + + @observable createdAt = new ReactiveValue(); + @observable updatedAt = new ReactiveValue(); + + @action.bound + closeModal() { + this.modalState.close(); + this.setViewMode(); + } + + anyInvalid(): boolean { + let invalid = this.groupName.invalid || this.students.invalid || this.directionOfPreparation.invalid; + + if (!this.editMode) { + invalid = invalid || !this.groupName.touched || !this.directionOfPreparation.touched; + } + + return invalid; + } + + touchAll() { + this.groupName.touch(); + this.students.touch(); + this.directionOfPreparation.touch(); + } + + @action.bound + save() { + if (this.anyInvalid()) { + this.touchAll(); + NotificationService.error("Пожалуйста, исправьте ошибки на форме"); + return; + } + + const studentIds: StudentData[] = this.students.value.map((s) => ({ + id: _.toNumber(s.value) + })); + + post("group/save", { + id: this.group.id ? this.group.id : undefined, + name: this.groupName.value, + students: studentIds, + preparationDirection: this.directionOfPreparation.value && this.directionOfPreparation.value[0] + ? {id: this.directionOfPreparation.value[0].value} + : undefined, + }).then(() => { + if (this.editMode) + NotificationService.success(`Профиль группы ${this.groupName.value} успешно обновлен`); + else + NotificationService.success(`Профиль группы ${this.groupName.value} успешно создан`); + }).finally(() => { + this.closeModal(); + }); + } + + @action.bound + setEditMode() { + this.editMode = true; + this.viewMode = false; + } + + @action.bound + setViewMode() { + this.editMode = false; + this.viewMode = true; + } + + @computed + get canManipulateGroups() { + return UserService.isSecretary || UserService.isAdministrator; + } + + @action.bound + deleteGroup() { + post(`group/delete?id=${this.group.id}`).then(() => { + NotificationService.success(`Группа ${this.groupName.value} удалена`); + }); + + this.closeModal(); + } +} + +interface GroupProfileModalProps { + creation?: boolean; + modalState: ModalState; + group?: Group; +} + +@observer +export default class GroupProfileModal extends ComponentContext { + constructor(props: GroupProfileModalProps) { + super(props); + makeObservable(this); + runInAction(() => { + this.fields = new GroupProfileState(this.props); + }); + } + + componentDidUpdate(prevProps: Readonly) { + if (prevProps.group !== this.props.group) { + runInAction(() => { + this.fields = new GroupProfileState(this.props); + }); + } + } + + @observable fields: GroupProfileState; + + render() { + return ( + + + + { + this.props.creation + ? `Создание группы` + : this.fields.editMode + ? `Редактирование профиля группы ${this.fields.groupName.value}` + : `Профиль группы ${this.fields.groupName.value}` + } + + + + + + + { + this.fields.viewMode && + <> + + + + } + + + { + this.props.creation && this.fields.canManipulateGroups && + + } + { + this.fields.editMode && this.fields.canManipulateGroups && + + } + { + !this.props.creation && !this.fields.editMode && this.props.group && this.fields.canManipulateGroups && + + } + { + !this.props.creation && this.props.group && this.fields.canManipulateGroups && !this.fields.editMode && + + } + + + + ); + } +} \ No newline at end of file diff --git a/web/src/components/group/GroupTableFilterModal.tsx b/web/src/components/group/GroupTableFilterModal.tsx new file mode 100644 index 0000000..b15821e --- /dev/null +++ b/web/src/components/group/GroupTableFilterModal.tsx @@ -0,0 +1,54 @@ +import {ModalState} from "../../utils/modalState"; +import {Group} from "../../models/group"; +import {observer} from "mobx-react"; +import React, {Component} from "react"; +import {action, makeObservable, observable, runInAction} from "mobx"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; +import {StringInput} from "../controls/ReactiveControls"; + +export interface GroupListFilterProps { + modalState: ModalState; + filters: ((group: Group) => boolean)[]; +} + +@observer +export class GroupListFilterModal extends Component { + constructor(props: GroupListFilterProps) { + super(props); + makeObservable(this); + + runInAction(() => { + this.filters.push(this.nameFilter); + }); + } + + @observable filters = this.props.filters; + @observable modalState = this.props.modalState; + @observable nameField = new ReactiveValue().syncWithParam('name'); + + @observable nameFilter = (group: Group) => { + if (!this.nameField.value) return true; + return group.name?.includes(this.nameField.value) + }; + + @action.bound + reset() { + this.nameField.set(""); + } + + render() { + return + + Фильтр + + + + + + + + + + } +} diff --git a/web/src/components/custom/layout/Error.tsx b/web/src/components/layout/Error.tsx similarity index 100% rename from web/src/components/custom/layout/Error.tsx rename to web/src/components/layout/Error.tsx diff --git a/web/src/components/custom/layout/Footer.tsx b/web/src/components/layout/Footer.tsx similarity index 75% rename from web/src/components/custom/layout/Footer.tsx rename to web/src/components/layout/Footer.tsx index e6b2bbc..ef03e5b 100644 --- a/web/src/components/custom/layout/Footer.tsx +++ b/web/src/components/layout/Footer.tsx @@ -1,9 +1,11 @@ -import {ComponentContext} from "../../../utils/ComponentContext"; +import {ComponentContext} from "../../utils/ComponentContext"; import {observer} from "mobx-react"; import {makeObservable} from "mobx"; import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {findIconDefinition} from "@fortawesome/fontawesome-svg-core"; +import {ThinkService} from "../../services/ThinkService"; +import {SysInfoService} from "../../services/SysInfoService"; @observer export class Footer extends ComponentContext { @@ -20,12 +22,12 @@ export class Footer extends ComponentContext {
Thesis Defence Management System — { - this.thinkStore.isThinking('updateVersion') && + ThinkService.isThinking('updateVersion') && } { - !this.thinkStore.isThinking('updateVersion') && - {this.sysInfoStore.version} + !ThinkService.isThinking('updateVersion') && + {SysInfoService.version} }
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx new file mode 100644 index 0000000..9e33dad --- /dev/null +++ b/web/src/components/layout/Header.tsx @@ -0,0 +1,118 @@ +import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap"; +import {RouterLink} from "mobx-state-router"; +import {observer} from "mobx-react"; +import {post} from "../../utils/request"; +import {UserLoginModal} from "../user/UserLoginModal"; +import {ModalState} from "../../utils/modalState"; +import {action, makeObservable, observable} from "mobx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {NotificationService} from "../../services/NotificationService"; +import {ThinkService} from "../../services/ThinkService"; +import ParticipantProfileModal from "../participant/ParticipantProfileModal"; +import GroupProfileModal from "../group/GroupProfileModal"; +import {fullName} from "../../models/participant"; +import {UserService} from "../../services/UserService"; + +@observer +export class Header extends ComponentContext { + constructor(props: any) { + super(props); + makeObservable(this); + } + + @observable loginModalState = new ModalState(); + @observable addGroupModalState = new ModalState(); + @observable userRegistrationModalState = new ModalState(); + + render() { + let userThink = ThinkService.isThinking('updateCurrentUser'); + + return <> +
+ + + + TDMS + + + + + +
+ + + + + } +} + +@observer +class AuthenticatedItems extends ComponentContext { + constructor(props: any) { + super(props); + makeObservable(this); + } + + @observable userParticipantProfileModalState = new ModalState(); + + @action.bound + logout() { + post('user/logout').then(() => { + UserService.updateCurrentUser(); + this.routerStore.goTo('home').then(); + NotificationService.success('Вы успешно вышли из системы', 'Выход'); + }); + } + + render() { + return <> + Пользователь: + + Мой профиль + + Выйти + + + + + } +} diff --git a/web/src/components/layout/Home.tsx b/web/src/components/layout/Home.tsx new file mode 100644 index 0000000..4dd0d64 --- /dev/null +++ b/web/src/components/layout/Home.tsx @@ -0,0 +1,18 @@ +import {Page} from "./Page"; +import {observer} from "mobx-react"; +import {makeObservable} from "mobx"; + +@observer +export class Home extends Page { + + constructor(props: any) { + super(props); + makeObservable(this); + } + + get page() { + return <> +

home

+ + } +} diff --git a/web/src/components/custom/layout/Page.tsx b/web/src/components/layout/Page.tsx similarity index 82% rename from web/src/components/custom/layout/Page.tsx rename to web/src/components/layout/Page.tsx index d839e29..0d5b6b8 100644 --- a/web/src/components/custom/layout/Page.tsx +++ b/web/src/components/layout/Page.tsx @@ -2,9 +2,10 @@ import {ReactNode} from "react"; import {Container} from "react-bootstrap"; import {Header} from "./Header"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {ComponentContext} from "../../../utils/ComponentContext"; -import {NotificationContainer} from "../../NotificationContainer"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {NotificationContainer} from "../NotificationContainer"; import {Footer} from "./Footer"; +import {ThinkService} from "../../services/ThinkService"; export abstract class Page extends ComponentContext { get page(): ReactNode { @@ -15,7 +16,7 @@ export abstract class Page extends ComponentContext { } render() { - const thinking = this.thinkStore.isThinking(); + const thinking = ThinkService.isThinking(); return <>
diff --git a/web/src/components/participant/ParticipantListPage.tsx b/web/src/components/participant/ParticipantListPage.tsx new file mode 100644 index 0000000..a2b3255 --- /dev/null +++ b/web/src/components/participant/ParticipantListPage.tsx @@ -0,0 +1,121 @@ +import {observer} from "mobx-react"; +import {action, makeObservable, observable, reaction, runInAction} from "mobx"; +import {Page} from "../layout/Page"; +import {get} from "../../utils/request"; +import {Column, TableDescriptor} from "../../utils/tables"; +import {ThinkService} from "../../services/ThinkService"; +import {fullName, Participant} from "../../models/participant"; +import {Role} from "../../models/roles"; +import {datetimeConverter} from "../../utils/converters"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {DataTable} from "../data-tables/DataTable"; +import ParticipantProfileModal from "./ParticipantProfileModal"; +import {ModalState} from "../../utils/modalState"; +import React from "react"; +import _ from "lodash"; + +class ParticipantListPageState { + constructor() { + makeObservable(this); + this.updateParticipants(); + reaction(() => this.participants, () => { + this.tableDescriptor = new TableDescriptor(this.tableColumns, this.participants.map(p => new ParticipantRecord(p))); + }, {fireImmediately: true}); + } + + @action.bound + updateParticipants() { + ThinkService.think(); + get('participant/get-all-participants').then((participants) => { + runInAction(() => { + this.participants = participants; + }); + }).finally(() => { + ThinkService.completeAll(); + }); + } + + @action.bound + onIconClicked(event: React.MouseEvent) { + const participant = this.participants.find(p => p.id === _.toNumber(event.currentTarget.getAttribute('data-value'))); + if (participant) { + this.selectedParticipant = participant; + this.participantModalState.open(); + } + } + + @observable participantModalState = new ModalState(); + + tableColumns = [ + new Column('fullName', 'Полное имя', undefined, (partic) => { + return ; + }), + new Column('roles', 'Роли', (value) => { + return value.length + ? value.map(role => {role.name}).reduce((prev, curr) => <>{prev}, {curr}) + : Нет ролей; + } + ), + new Column('email', 'Email'), + new Column('phone', 'Телефон'), + new Column('createdAt', 'Дата создания', (value: string) => value ? datetimeConverter(value) : 'Не создавалось'), + new Column('updatedAt', 'Дата обновления', (value: string) => value ? datetimeConverter(value) : 'Не обновлялось'), + ]; + + @observable participants: Participant[] = []; + @observable selectedParticipant?: Participant; + @observable tableDescriptor?: TableDescriptor; +} + +class ParticipantRecord { + original: Participant; + firstName: string; + lastName: string; + middleName: string; + fullName: string; + roles: Role[]; + email: string; + phone: string; + createdAt: string; + updatedAt: string; + + constructor(participant: Participant) { + this.original = participant; + this.firstName = participant.firstName; + this.lastName = participant.lastName; + this.middleName = participant.middleName; + this.fullName = fullName(participant); + this.roles = participant.roles; + this.email = participant.email; + this.phone = participant.numberPhone; + this.createdAt = participant.createdAt; + this.updatedAt = participant.updatedAt; + } +} + +@observer +export class ParticipantListPage extends Page { + constructor(props: {}) { + super(props); + makeObservable(this); + } + + state = new ParticipantListPageState(); + + componentDidMount() { + this.state.updateParticipants(); + } + + get page() { + return <> + { + this.state.tableDescriptor && + + } + { + this.state.selectedParticipant && + + } + + } +} \ No newline at end of file diff --git a/web/src/components/participant/ParticipantProfileModal.tsx b/web/src/components/participant/ParticipantProfileModal.tsx new file mode 100644 index 0000000..bf6f866 --- /dev/null +++ b/web/src/components/participant/ParticipantProfileModal.tsx @@ -0,0 +1,478 @@ +import {action, computed, makeObservable, observable, reaction} from "mobx"; +import {ComponentContext} from "../../utils/ComponentContext"; +import {ModalState} from "../../utils/modalState"; +import {fullName, Participant} from "../../models/participant"; +import {observer} from "mobx-react"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Tab, Tabs} from "react-bootstrap"; +import {BooleanInput, DropdownSelectInput, PasswordInput, SelectInputValue, StringInput} from "../controls/ReactiveControls"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {getRoleArrayByCode, isRolePresent, Roles, RolesArray} from "../../models/roles"; +import {datetimeConverter} from "../../utils/converters"; +import { + email, + emailChars, + loginChars, + loginLength, + loginMaxLength, + nameChars, + nameLength, + password, + passwordChars, + passwordLength, + passwordMaxLength, + phone, + phoneChars, + required +} from "../../utils/reactive/validators"; +import {NotificationService} from "../../services/NotificationService"; +import {get, post} from "../../utils/request"; +import {ParticipantSaveDTO} from "../../models/registration"; +import {Group} from "../../models/group"; +import _ from "lodash"; +import {StudentData} from "../../models/studentData"; +import {UserService} from "../../services/UserService"; +import {RouterService} from "../../services/RouterService"; +import { TeacherData } from "../../models/teacherData"; + +const RolesAsSelectValues = RolesArray.map(role => { + return { + value: role.code, + label: role.name + } as SelectInputValue; +}).sort((a, b) => a.label.localeCompare(b.label)); + +let allGroups: Group[] = []; +let allTeachers: TeacherData[] = []; + +const getAllTeachers = () => { + return get("teacher-data/get-all"); +} + +class ParticipantProfileState { + constructor(props: ParticipantProfileModalProps) { + makeObservable(this); + this.modalState = props.modalState; + this.participant = !props.registration ? props.participant : { + id: undefined, + firstName: "", + lastName: "", + middleName: "", + numberPhone: "", + user: undefined, + email: "", + roles: [], + updatedAt: "", + createdAt: "", + }; + this.fields = new ReactiveFields(this.participant, this); + this.viewMode = !props.registration; + + get("/group/get-all-groups").then(data => { + allGroups = data; + }); + + getAllTeachers().then(data => {allTeachers = data;}); + } + + @observable viewMode: boolean; + @observable editMode: boolean; + @observable participant: Participant; + @observable fields: ReactiveFields; + @observable modalState: ModalState; + + @action.bound + close() { + this.modalState.close(); + this.editMode = false; + this.viewMode = true; + } + + @action.bound + save() { + if (this.fields.anyInvalid()) { + this.fields.touchAll(); + NotificationService.error("Пожалуйста, исправьте ошибки на форме"); + return; + } + + let data: ParticipantSaveDTO = { + id: this.participant ? this.participant.id : undefined, + firstName: this.fields.firstName.value, + lastName: this.fields.lastName.value, + middleName: this.fields.middleName.value, + email: this.fields.email.value, + numberPhone: this.fields.phone.value, + authorities: UserService.isSecretary || UserService.isAdministrator ? this.fields.roles.value.map(role => role.value) : undefined, + userData: this.fields.userPresent.value ? { + login: this.fields.userLogin.touched && !_.isEmpty(this.fields.userLogin.value) ? this.fields.userLogin.value : undefined, + password: this.fields.userPassword.touched && !_.isEmpty(this.fields.userPassword.value) ? this.fields.userPassword.value : undefined, + } : undefined, + studentData: this.fields.studentPresent.value ? { + groupId: _.toNumber(this.fields.studentGroup.value[0]?.value), + curatorId: _.toNumber(this.fields.studentCurator.value[0]?.value), + } : undefined, + teacherData: this.fields.teacherPresent.value ? { + degree: this.fields.teacherDegree.value, + } : undefined, + } + + post("participant/save", data).then(() => { + if (this.editMode) { + NotificationService.success(`Профиль участника успешно обновлен`); + } else { + NotificationService.success(`Профиль участника успешно зарегистрирован`); + } + + if (this.isOwnRecord && this.credentialsChanged) { + NotificationService.success("Ваш профиль был обновлен, пожалуйста, перезайдите в систему"); + UserService.updateCurrentUser(() => { + RouterService.redirect('home'); + }); + } + }).finally(() => { + this.modalState.close(); + if (this.editMode) { + this.exitEditMode(); + } + }); + } + + @computed + get canChangeRoles(): boolean { + return UserService.isAdministrator || UserService.isSecretary; + } + + @computed + get canViewUserTab(): boolean { + return UserService.isAdministrator || UserService.isSecretary || this.isOwnRecord; + } + + @computed + get isOwnRecord(): boolean { + return this.participant?.id === UserService.user?.participant?.id; + } + + @computed + get credentialsChanged(): boolean { + return !_.isEmpty(this.fields.userPassword.value) + || (!_.isEmpty(this.fields.userLogin.value) && this.fields.userLogin.value !== this.participant?.user?.login); + } + + @action.bound + enterEditMode() { + this.viewMode = false; + this.editMode = true; + } + + @action.bound + exitEditMode() { + this.viewMode = true; + this.editMode = false; + } + + @action.bound + delete() { + post("participant/delete", {id: this.participant.id}).then(() => { + console.log("participant/delete", this.participant); + NotificationService.success(`Профиль участника успешно удален`); + if (this.isOwnRecord && this.credentialsChanged) { + NotificationService.success("Ваш профиль был обновлен, пожалуйста, перезайдите в систему"); + UserService.updateCurrentUser(() => { + RouterService.redirect('home'); + }); + } + }).finally(() => { + this.modalState.close(); + this.exitEditMode(); + }); + } +} + +class ReactiveFields { + constructor(participant: Participant, state: ParticipantProfileState) { + makeObservable(this); + this.state = state; + this.firstName.setAuto(participant.firstName); + this.lastName.setAuto(participant.lastName); + this.middleName.setAuto(participant.middleName); + this.email.setAuto(participant.email); + this.phone.setAuto(participant.numberPhone); + this.roles.setAuto(participant.roles.map(role => { + return { + value: role.code, + label: role.name + }; + })); + this.createdAt.setAuto(datetimeConverter(participant.createdAt)); + this.updatedAt.setAuto(datetimeConverter(participant.updatedAt)); + + this.userPresent.setAuto(participant.user != null); + if (this.userPresent.value) { + this.userLogin.setAuto(participant.user?.login); + this.userCreatedAt.setAuto(datetimeConverter(participant.user?.createdAt)); + this.userUpdatedAt.setAuto(datetimeConverter(participant.user?.updatedAt)); + this.userPassword.setAuto(''); + } + + reaction(() => this.roles.value, () => { + let rolesArray = getRoleArrayByCode(this.roles.value.map( + role => ({code: role.value} as { + code: string + } + ))); + this.studentPresent.setAuto(isRolePresent(Roles.STUDENT.code, rolesArray)); + this.teacherPresent.setAuto(isRolePresent(Roles.TEACHER.code, rolesArray)); + }, {fireImmediately: true}); + + if (this.studentPresent.value) { + get('/student/by-partic-id', {id: participant.id}).then(data => { + if (data.group) { + this.studentGroup.setAuto([{ + value: _.toString(data.group.id), + label: allGroups.find(group => group.id === data.group.id)?.name + } as SelectInputValue]); + } + + if (data.curator) { + this.studentCurator.setAuto([{ + value: _.toString(data.curator.id), + label: fullName(allTeachers.find(t => t.id === data.curator.id)?.participant) + }]); + } + + this.studentDiplomaTopic.setAuto(data.diplomaTopic?.name); + }); + } + + if (this.teacherPresent.value) { + get('teacher-data/by-partic-id', {id: participant.id}).then(data => { + this.teacherDegree.setAuto(data.degree); + }); + } + } + + @observable state : ParticipantProfileState; + + /* PARTICIPANT */ + @observable firstName = new ReactiveValue().addValidator(required).addValidator(nameLength).addInputRestriction(nameChars); + @observable lastName = new ReactiveValue().addValidator(required).addValidator(nameLength).addInputRestriction(nameChars); + @observable middleName = new ReactiveValue().addInputRestriction(nameChars); + @observable email = new ReactiveValue().addValidator(required).addValidator(email).addInputRestriction(emailChars); + @observable phone = new ReactiveValue().addValidator(required).addValidator(phone).addInputRestriction(phoneChars); + @observable roles = new ReactiveValue(); + @observable createdAt = new ReactiveValue(); + @observable updatedAt = new ReactiveValue(); + + /* USER */ + @observable userPresent = new ReactiveValue(); + @observable userLogin = new ReactiveValue().addValidator((value, field) => { + if (this.userPresent.value && !this.state.editMode) + return required(value, field); + }).addValidator((value, field) => { + if (this.userPresent.value) + return loginLength(value, field); + }).addInputRestriction(loginChars).addInputRestriction(loginMaxLength); + @observable userPassword = new ReactiveValue() + .addValidator((v, f) => { + if (this.userPresent.value && !this.state.editMode) + return required(v, f); + }).addValidator((v, f) => { + if (this.userPresent.value && (!this.state.editMode || !_.isEmpty(this.userPassword.value))) + return password(v, f); + }).addValidator((v, f) => { + if (this.userPresent.value && (!this.state.editMode || !_.isEmpty(this.userPassword.value))) + return passwordLength(v, f); + }).addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength); + @observable userCreatedAt = new ReactiveValue(); + @observable userUpdatedAt = new ReactiveValue(); + + /* STUDENT */ + @observable studentPresent = new ReactiveValue(); + @observable studentGroup = new ReactiveValue(); + @observable studentCurator = new ReactiveValue(); + @observable studentDiplomaTopic = new ReactiveValue(); + + /* TEACHER */ + @observable teacherPresent = new ReactiveValue(); + @observable teacherDegree = new ReactiveValue().addValidator(required); + + anyInvalid(): boolean { + let reqInvalid = this.firstName.invalid + || this.lastName.invalid || this.middleName.invalid + || this.email.invalid || this.email.invalid + || this.phone.invalid || this.roles.invalid; + + if (this.userPresent.value) { + reqInvalid = reqInvalid || this.userLogin.invalid || this.userPassword.invalid; + } + + if (this.studentPresent.value) { + reqInvalid = reqInvalid || this.studentGroup.invalid || this.studentCurator.invalid; + } + + if (this.teacherPresent.value) { + reqInvalid = reqInvalid || this.teacherDegree.invalid; + } + + if (!this.state.editMode) { + reqInvalid = reqInvalid || !this.firstName.touched || !this.lastName.touched; + reqInvalid = reqInvalid || !this.email.touched || !this.phone.touched; + if (this.userPresent.value) { + reqInvalid = reqInvalid || !this.userLogin.touched || !this.userPassword.touched; + } + } + + return reqInvalid; + } + + touchAll() { + this.firstName.touch(); + this.lastName.touch(); + this.middleName.touch(); + this.email.touch(); + this.phone.touch(); + this.roles.touch(); + + if (this.userPresent.value) { + this.userLogin.touch(); + this.userPassword.touch(); + } + + if (this.studentPresent.value) { + this.studentGroup.touch(); + this.studentCurator.touch(); + } + + if (this.teacherPresent.value) { + this.teacherDegree.touch(); + } + } +} + +interface ParticipantProfileModalProps { + registration?: boolean; + modalState: ModalState; + participant?: Participant; +} + +@observer +export default class ParticipantProfileModal extends ComponentContext { + constructor(props: ParticipantProfileModalProps) { + super(props); + makeObservable(this); + this.setState(new ParticipantProfileState(this.props)); + } + + componentDidUpdate(prevProps: Readonly) { + if (prevProps.participant !== this.props.participant) { + this.setState(new ParticipantProfileState(this.props)); + } + } + + @observable myState: ParticipantProfileState; + + @action.bound + setState(state: ParticipantProfileState) { + this.myState = state; + } + + render() { + return ( + + + + { + this.props.registration + ? "Регистрация участника" + : this.myState.editMode + ? `Редактирование профиля участника ${fullName(this.myState.participant)}` + : `Профиль участника ${fullName(this.myState.participant)}` + } + + + + + + + + + + + + { + this.myState.viewMode && + <> + + + + } + + { + this.myState.canViewUserTab && + + + { + this.myState.fields.userPresent.value && + <> + + { + (this.props.registration || this.myState.editMode) && + + } + { + this.myState.viewMode && + <> + + + + } + + } + + } + { + this.myState.fields.studentPresent.value && + + { + return {value: _.toString(value.id), label: value.name} as SelectInputValue; + })} label={"Группа"} value={this.myState.fields.studentGroup} filtering singleSelect disabled={this.myState.viewMode}/> + { + return {value: _.toString(value.id), label: fullName(value.participant)} as SelectInputValue; + })} label={"Куратор"} value={this.myState.fields.studentCurator} filtering singleSelect disabled={this.myState.viewMode}/> + + + } + { + this.myState.fields.teacherPresent.value && + + + + } + + + + { + this.props.registration && + + } + { + !this.props.registration && this.myState.editMode && + + } + { !this.props.registration && !this.myState.editMode + && (UserService.isAdministrator || UserService.isSecretary || this.myState.isOwnRecord) && + + } + { !this.props.registration && (UserService.isAdministrator || UserService.isSecretary) && + + } + + + + ); + } +} \ No newline at end of file diff --git a/web/src/components/tasks/DiplomaTopicAgreement.tsx b/web/src/components/tasks/DiplomaTopicAgreement.tsx new file mode 100644 index 0000000..0465e5d --- /dev/null +++ b/web/src/components/tasks/DiplomaTopicAgreement.tsx @@ -0,0 +1,52 @@ +import {ModalState} from "../../utils/modalState"; +import {observer} from "mobx-react"; +import {makeObservable, observable} from "mobx"; +import React from "react"; +import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; +import {StringInput} from "../controls/ReactiveControls"; +import {ReactiveValue} from "../../utils/reactive/reactiveValue"; +import {required} from "../../utils/reactive/validators"; + +class Fields { + constructor(modalState: ModalState) { + makeObservable(this); + this.modalState = modalState; + } + + @observable modalState: ModalState; + + @observable studentName = new ReactiveValue().addValidator(required); + @observable curatorName = new ReactiveValue().addValidator(required); + @observable topicName = new ReactiveValue().addValidator(required); + @observable canChangeTopic = false; +} + +@observer +class DiplomaTopicAgreementModal extends React.Component<{ modalState: ModalState }> { + constructor(props: any) { + super(props); + makeObservable(this); + this.fields = new Fields(props.modalState); + } + + @observable fields: Fields; + + render() { + return ( + + + Согласование темы ВКР + + + + + + + + + + + + ); + } +} \ No newline at end of file diff --git a/web/src/components/user/StudentProfile.tsx b/web/src/components/user/StudentProfile.tsx deleted file mode 100644 index 8a9c8ef..0000000 --- a/web/src/components/user/StudentProfile.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import {ComponentContext} from "../../utils/ComponentContext"; -import {IStudent} from "../../models/student"; -import {observer} from "mobx-react"; -import {computed, makeObservable, observable, reaction} from "mobx"; -import {Col, Row} from "react-bootstrap"; -import {StringInput} from "../custom/controls/ReactiveControls"; -import {ReactiveValue} from "../../utils/reactive/reactiveValue"; -import _ from "lodash"; - -export interface StudentProfileProps { - student: IStudent; - viewMode: "VIEW" | "EDIT"; -} - -@observer -export class StudentProfile extends ComponentContext { - @observable student: IStudent = this.props.student; - @observable viewMode: "VIEW" | "EDIT" = this.props.viewMode; - - @observable form = new ReactiveValue().setAuto(this.student.form.toString()); - @observable protectionOrder = new ReactiveValue().setAuto(_.toString(this.student.protectionOrder)); - @observable magistracy = new ReactiveValue().setAuto(this.student.magistracy); - @observable digitalFormatPresent = new ReactiveValue().setAuto(this.student.digitalFormatPresent.toString()); - @observable markComment = new ReactiveValue().setAuto(_.toString(this.student.markComment)); - @observable markPractice = new ReactiveValue().setAuto(_.toString(this.student.markPractice)); - @observable predefenceComment = new ReactiveValue().setAuto(this.student.predefenceComment); - @observable normalControl = new ReactiveValue().setAuto(this.student.normalControl); - @observable antiPlagiarism = new ReactiveValue().setAuto(_.toString(this.student.antiPlagiarism)); - @observable note = new ReactiveValue().setAuto(this.student.note); - @observable recordBookReturned = new ReactiveValue().setAuto(this.student.recordBookReturned.toString()); - @observable work = new ReactiveValue().setAuto(this.student.work); - @observable diplomaTopic = new ReactiveValue().setAuto(this.student.diplomaTopic); - @observable mentorUser = new ReactiveValue().setAuto(this.student.mentorUser.fullName); - @observable group = new ReactiveValue().setAuto(this.student.group.name); - - constructor(props: any) { - super(props); - makeObservable(this); - - reaction(() => this.props.viewMode, (viewMode) => { - this.viewMode = viewMode; - }); - - reaction(() => this.props.student, (student) => { - this.form.set(student.form.toString()); - this.protectionOrder.set(_.toString(student.protectionOrder)); - this.magistracy.set(student.magistracy); - this.digitalFormatPresent.set(student.digitalFormatPresent.toString()); - this.markComment.set(_.toString(student.markComment)); - this.markPractice.set(_.toString(student.markPractice)); - this.predefenceComment.set(student.predefenceComment); - this.normalControl.set(student.normalControl); - this.antiPlagiarism.set(_.toString(student.antiPlagiarism)); - this.note.set(student.note); - this.recordBookReturned.set(student.recordBookReturned.toString()); - this.work.set(student.work); - this.diplomaTopic.set(student.diplomaTopic); - this.mentorUser.set(student.mentorUser.fullName); - this.group.set(student.group.name); - }); - - reaction(() => { - return { - form: this.form.value, - protectionOrder: this.protectionOrder.value, - magistracy: this.magistracy.value, - digitalFormatPresent: this.digitalFormatPresent.value, - markComment: this.markComment.value, - markPractice: this.markPractice.value, - predefenceComment: this.predefenceComment.value, - normalControl: this.normalControl.value, - antiPlagiarism: this.antiPlagiarism.value, - note: this.note.value, - recordBookReturned: this.recordBookReturned.value, - work: this.work.value, - diplomaTopic: this.diplomaTopic.value, - mentorUser: this.mentorUser.value, - group: this.group.value - } - }, () => { - this.student = { - ...this.student, - form: this.form.value === "true", - protectionOrder: _.toNumber(this.protectionOrder.value), - magistracy: this.magistracy.value, - digitalFormatPresent: this.digitalFormatPresent.value === "true", - markComment: _.toNumber(this.markComment.value), - markPractice: _.toNumber(this.markPractice.value), - predefenceComment: this.predefenceComment.value, - normalControl: this.normalControl.value, - antiPlagiarism: _.toNumber(this.antiPlagiarism.value), - note: this.note.value, - recordBookReturned: this.recordBookReturned.value === "true", - work: this.work.value, - diplomaTopic: this.diplomaTopic.value, - group: {name: this.group.value} - } - }); - } - - @computed - get viewOnly() { - return this.viewMode === "VIEW"; - } - - render() { - return
- - - - - - - - - - - - - - - - - - - - - -
; - } -} \ No newline at end of file diff --git a/web/src/components/user/StudentProfileModal.tsx b/web/src/components/user/StudentProfileModal.tsx deleted file mode 100644 index a86a8b6..0000000 --- a/web/src/components/user/StudentProfileModal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import {IStudent} from "../../models/student"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {action, makeObservable, observable} from "mobx"; -import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap"; -import {StudentProfile} from "./StudentProfile"; -import {ModalState} from "../../utils/modalState"; - -export interface StudentProfileProps { - modalState: ModalState; - fullName: string; - student: IStudent; -} - -export class StudentProfileModal extends ComponentContext { - @observable modalState: ModalState = this.props.modalState; - @observable student: IStudent = this.props.student; - @observable viewMode: "VIEW" | "EDIT" = "VIEW"; - - constructor(props: any) { - super(props); - makeObservable(this); - } - - @action.bound - onEdit() { - this.viewMode = "EDIT"; - } - - @action.bound - onSave() { - this.modalState.close(); - this.viewMode = "VIEW"; - this.notificationStore.warn("Не реализовано"); - } - - render() { - return - - Профиль студента: {this.props.fullName} - - - - - - { - this.viewMode === "EDIT" && - - } - { - this.viewMode === "VIEW" && - - } - - - ; - } -} \ No newline at end of file diff --git a/web/src/components/user/UserListPage.tsx b/web/src/components/user/UserListPage.tsx deleted file mode 100644 index 25b2c5c..0000000 --- a/web/src/components/user/UserListPage.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import {observer} from "mobx-react"; -import {action, makeObservable, observable, reaction, runInAction} from "mobx"; -import {Page} from "../custom/layout/Page"; -import {IAuthenticated} from "../../models/user"; -import {DataTable} from "../custom/DataTable"; -import {get} from "../../utils/request"; -import {Column, TableDescriptor} from "../../utils/tables"; -import {Authorities, getAuthorityByCode} from "../../models/authorities"; -import {datetimeConverter} from "../../utils/converters"; -import {StudentProfileModal} from "./StudentProfileModal"; -import {ModalState} from "../../utils/modalState"; -import {IStudent} from "../../models/student"; - -@observer -export class UserListPage extends Page { - constructor(props: {}) { - super(props); - makeObservable(this); - } - - componentDidMount() { - this.requestUsers(); - reaction(() => this.users, () => { - if (typeof this.users === 'undefined') { - return; - } - - this.tableDescriptor = new TableDescriptor(this.userColumns, this.users); - }, {fireImmediately: true}); - } - - @observable users?: IAuthenticated[]; - @observable tableDescriptor?: TableDescriptor; - - @observable studentModalState = new ModalState(); - @observable studentFullName: string = ''; - @observable student: IStudent; - - userColumns = [ - new Column('login', 'Логин'), - new Column('fullName', 'Полное имя'), - new Column('authorities', 'Роли', (value, user) => { - return value.map(getAuthorityByCode).map(authority => { - if (authority.code === Authorities.STUDENT.code) { - return { - console.log(user.id); - get('student/by-user-id', {id: user.id}).then((student) => { - runInAction(() => { - this.studentFullName = user.fullName; - this.student = student; - this.studentModalState.open(); - }); - }); - }}>{authority.name}; - } else { - return {authority.name}; - } - }).reduce((prev, curr) => <> - {prev}, {curr} - ); - }), - new Column('email', 'Email'), - new Column('phone', 'Телефон'), - new Column('createdAt', 'Дата создания', (value: string) => value ? datetimeConverter(value) : 'Не создавалось'), - new Column('updatedAt', 'Дата обновления', (value: string) => value ? datetimeConverter(value) : 'Не обновлялось'), - ]; - - - @action.bound - requestUsers() { - this.thinkStore.think('userList'); - get('user/get-all').then((users) => { - runInAction(() => { - this.users = users; - }); - }).finally(() => { - this.thinkStore.completeAll('userList'); - }); - } - - get page() { - return <> - { - this.tableDescriptor && - <> - - { - this.student && - - } - - - } - - } -} \ No newline at end of file diff --git a/web/src/components/user/UserLoginModal.tsx b/web/src/components/user/UserLoginModal.tsx index 69a0652..7e34a0b 100644 --- a/web/src/components/user/UserLoginModal.tsx +++ b/web/src/components/user/UserLoginModal.tsx @@ -7,7 +7,11 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {ComponentContext} from "../../utils/ComponentContext"; import {ReactiveValue} from "../../utils/reactive/reactiveValue"; import {loginChars, loginLength, loginMaxLength, password, passwordChars, passwordLength, passwordMaxLength, required} from "../../utils/reactive/validators"; -import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls"; +import {PasswordInput, StringInput} from "../controls/ReactiveControls"; +import {ThinkService} from "../../services/ThinkService"; +import {NotificationService} from "../../services/NotificationService"; +import {fullName} from "../../models/participant"; +import {UserService} from "../../services/UserService"; interface LoginModalProps { modalState: ModalState; @@ -38,29 +42,28 @@ export class UserLoginModal extends ComponentContext { if (this.modalInvalid) return; - this.thinkStore.think('loginModal'); + ThinkService.think('loginModal'); post('user/login', { login: this.login.value, password: this.password.value }).then(() => { - this.userStore.updateCurrentUser((user) => { - if (user.authenticated) { - this.routerStore.goTo('profile').then(); - this.notificationStore.success('Вы успешно вошли в систему, ' + user.fullName, 'Успешный вход'); + UserService.updateCurrentUser((user) => { + this.routerStore.goTo('home').then(); + if (user != null) { + NotificationService.success('Вы успешно вошли в систему, ' + fullName(user.participant), 'Успешный вход'); } else { - this.routerStore.goTo('root').then(); - this.notificationStore.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа'); + NotificationService.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа'); } }); }).finally(() => { this.props.modalState.close(); - this.thinkStore.completeAll('loginModal'); + ThinkService.completeAll('loginModal'); }); } render() { const open = this.props.modalState.isOpen; - const thinking = this.thinkStore.isThinking('loginModal'); + const thinking = ThinkService.isThinking('loginModal'); return diff --git a/web/src/components/user/UserProfile.tsx b/web/src/components/user/UserProfile.tsx deleted file mode 100644 index 4ef76b9..0000000 --- a/web/src/components/user/UserProfile.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ComponentContext} from "../../utils/ComponentContext"; -import {IUser} from "../../models/user"; -import {IStudent} from "../../models/student"; -import {ITeacher} from "../../models/teacher"; - -export interface UserProfileProps { - user: IUser; - student?: IStudent; - teacher?: ITeacher; -} - -export class UserProfile extends ComponentContext { - -} \ No newline at end of file diff --git a/web/src/components/user/UserProfilePage.tsx b/web/src/components/user/UserProfilePage.tsx deleted file mode 100644 index 890a34c..0000000 --- a/web/src/components/user/UserProfilePage.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import {Page} from "../custom/layout/Page"; -import {Col, Form, Row} from "react-bootstrap"; -import {observer} from "mobx-react"; -import {IStudent} from "../../models/student"; -import {action, makeObservable, observable, runInAction} from "mobx"; -import {IAuthenticated} from "../../models/user"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {getAuthorityByCode} from "../../models/authorities"; -import {datetimeConverter} from "../../utils/converters"; - -@observer -class UserInfo extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - componentDidMount() { - runInAction(() => { - this.user = this.userStore.user as IAuthenticated; - }); - } - - @observable user: IAuthenticated; - - render() { - return
- { - this.user && - - - - ФИО - - - - Имя пользователя - - - - Электронная почта - - - - Телефон - {/* todo: format phone */} - - - - - - Роли - a.code).join(', ')} - disabled={true}/> - - - Дата создания - - - - Дата последней модификации - - - - - } -
- } -} - -@observer -class StudentInfo extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - componentDidMount() { - runInAction(() => { - this.student = this.userStore.student; - }); - } - - @observable student: IStudent; - - render() { - let student = this.student; - - return ( - - - - Тема дипломной работы - - - - Очередь защиты - - - - Презентация в электронном формате - - - - Оценка за комментарий {/* todo: обсудить с аналитиком */} - - - - Оценка за практику {/* todo: обсудить с аналитиком */} - - - - Комментарий к предзащите - - - - Форма контроля {/* todo: обсудить с аналитиком */} - - - - Антиплагиат (процент - уникальности) {/* todo: обсудить с аналитиком */} - - - - Примечание - - - - - - Группа - - - - Куратор - - - - Форма обучения {/* todo: обсудить с аналитиком */} - - - - Научный руководитель - - - - Зачетная книжка сдана - - - - Работа {/* todo: обсудить с аналитиком */} - - - - Магистратура {/* todo: обсудить с аналитиком */} - - - - - ); - } -} - -@observer -export class UserProfilePage extends Page { - constructor(props: any) { - super(props); - makeObservable(this); - } - - componentDidMount() { - runInAction(() => { - this.user = this.userStore.user as IAuthenticated; - this.student = this.userStore.student; - }); - this.redirectIfNotAuthenticated(); - } - - componentDidUpdate() { - runInAction(() => { - this.user = this.userStore.user as IAuthenticated; - this.student = this.userStore.student; - }); - this.redirectIfNotAuthenticated(); - } - - @observable user: IAuthenticated; - @observable student: IStudent; - - @action.bound - redirectIfNotAuthenticated() { - if (this.thinkStore.isThinking('updateCurrentUser')) { - return; - } - - if (!this.user.authenticated) { - this.routerStore.goToNotFound(); - } - } - - get page() { - return <> - { - !this.thinkStore.isThinking('updateCurrentUser') && -
- { - this.user?.authenticated && - - } - { - this.student && - - } - - } - - } -} diff --git a/web/src/components/user/UserRegistrationModal.tsx b/web/src/components/user/UserRegistrationModal.tsx deleted file mode 100644 index 0663540..0000000 --- a/web/src/components/user/UserRegistrationModal.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import {observer} from "mobx-react"; -import {action, makeObservable, observable} from "mobx"; -import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row} from "react-bootstrap"; -import {UserRegistrationDTO} from "../../models/registration"; -import {post} from "../../utils/request"; -import {ReactiveValue} from "../../utils/reactive/reactiveValue"; -import {PasswordInput, ReactiveSelectInputSelect, SelectInput, StringInput} from "../custom/controls/ReactiveControls"; -import { - email, - emailChars, - loginChars, - loginLength, - loginMaxLength, - nameChars, - nameLength, - password, - passwordChars, - passwordLength, - passwordMaxLength, - phone, - phoneChars, - required, - selected -} from "../../utils/reactive/validators"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {ModalState} from "../../utils/modalState"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {Authorities} from "../../models/authorities"; - -export interface UserRegistrationModalProps { - modalState: ModalState; -} - -@observer -export class UserRegistrationModal extends ComponentContext { - constructor(props: any) { - super(props); - makeObservable(this); - } - - @observable login = new ReactiveValue() - .addValidator(required).addValidator(loginLength) - .addInputRestriction(loginChars).addInputRestriction(loginMaxLength); - @observable password = new ReactiveValue() - .addValidator(required).addValidator(password).addValidator(passwordLength) - .addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength); - @observable fullName = new ReactiveValue() - .addValidator(required).addValidator(nameLength) - .addInputRestriction(nameChars); - @observable email = new ReactiveValue() - .addValidator(required).addValidator(email) - .addInputRestriction(emailChars); - @observable numberPhone = new ReactiveValue().setAuto('+7') - .addValidator(required).addValidator(phone) - .addInputRestriction(phoneChars); - @observable userKindEnum = new ReactiveValue().addValidator(required); - - @observable accountType = new ReactiveValue().addValidator(selected).addValidator((value) => { - if (value.value === Authorities.SECRETARY.code || value.value === Authorities.COMMISSION_MEMBER.code) { - return 'не реализовано' - } - }); - - @observable teacherGroup = new ReactiveValue().addValidator(selected); - @observable teacherStudent = new ReactiveValue().addValidator(selected); - - @observable studentGroup = new ReactiveValue().addValidator(selected); - - possibleAccountTypes(): ReactiveSelectInputSelect[] { - const teacher = {value: Authorities.TEACHER.code, label: Authorities.TEACHER.name} as ReactiveSelectInputSelect; - const student = {value: Authorities.STUDENT.code, label: Authorities.STUDENT.name} as ReactiveSelectInputSelect; - const commissionMember = {value: Authorities.COMMISSION_MEMBER.code, label: Authorities.COMMISSION_MEMBER.name} as ReactiveSelectInputSelect; - const administrator = {value: Authorities.ADMINISTRATOR.code, label: Authorities.ADMINISTRATOR.name} as ReactiveSelectInputSelect; - const secretary = {value: Authorities.SECRETARY.code, label: Authorities.SECRETARY.name} as ReactiveSelectInputSelect; - - if (this.userStore.isAdministrator) { - return [teacher, student, commissionMember, administrator, secretary]; - } else if (this.userStore.isSecretary) { - return [teacher, student, commissionMember]; - } else { - return []; - } - } - - get formInvalid() { - return this.login.invalid || !this.login.touched - || this.password.invalid || !this.password.touched - || this.fullName.invalid || !this.fullName.touched - || this.email.invalid || !this.email.touched - || this.numberPhone.invalid || !this.numberPhone.touched - || this.accountType.invalid || !this.accountType.touched - || this.thinkStore.isThinking('userRegistration'); - } - - @action.bound - submit() { - if (this.formInvalid) - return; - - this.thinkStore.think('userRegistration'); - post('user/register', { - login: this.login.value, - password: this.password.value, - fullName: this.fullName.value, - email: this.email.value, - numberPhone: this.numberPhone.value, - accountType: this.accountType.value.value, - studentData: this.accountType.value.value === Authorities.STUDENT.code ? { - groupId: 1 - } : undefined, - teacherData: this.accountType.value.value === Authorities.TEACHER.code ? { - curatingGroups: [], - advisingStudents: [], - } : undefined, - } as UserRegistrationDTO).then(() => { - this.notificationStore.success('Пользователь успешно зарегистрирован'); - }).catch(() => { - this.notificationStore.error('Ошибка регистрации пользователя'); - }).finally(() => { - this.thinkStore.completeOne('userRegistration'); - this.props.modalState.close(); - }); - } - - render() { - const thinking = this.thinkStore.isThinking('userRegistration'); - - return - - Регистрация пользователя - - { - thinking && - -
- -
-
- } - { - !thinking && - - - - - - - - - - - - - - - { - this.accountType.value?.value === Authorities.STUDENT.code && - - - - - - } - - { - this.accountType.value?.value === Authorities.TEACHER.code && - - - - - - - } - - } - - - - -
- } -} \ No newline at end of file diff --git a/web/src/favicon.png b/web/src/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..6f21ede2b78dcb711563858aaeadf8f1a8761e92 GIT binary patch literal 97365 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

    n` z@cZLql$C-ZY5yDX?L$-^)GE7z6)YrIcY@BSIte2(026F0N_885&hD8d;wbLELo9)O zXjIU_2o?E!Vt8C|;cqFrI$+DxSed*G%sQZx3AkqmB1D-e=&BLFyk3u&_MHDgr{o+t z&o5c>qwBAuk7RVaq?kum*UWZ6O-=0_7zUgbd zfcdlf$ z7p^XnS~=<3j+i$ic%12tEIT@uh+Ut~aamUs@D6QQuN+dSKu+qy%e^>97wJy|`0G@{VXp$aCGJ zJVvH}{>0)!iZFnM)OCiy46;Ht@OMYJGoX2e2Trk`3=~@{iX#8G`qOU1aFv#y0V?Ma zuBb49UNn)oGZD3lyI0c{v(uV15uq3F*4cO43=%NI&F)=kL(%spIUlhzm$_GGOr-jZ z4rYgF+9@(|-q;mEy+mfK6%r2YV{#DEAqu>E3AmHONm!{nnuF5$$LeV?zz#EAkA)#r zw_^~so#a=-hj>(^WeT8>ed`Ut!E3RSC6w;tm+K>0AEp7n@is#VK1SL=B#`pKsHF4X zo9*17!=~+=nlnVQ=8Mg65)ComJCP{)$OM3ujpx0RKV^7sA~xJoNp*J44xzGacfb{# zf{%qj~l<{YdszXtV04xT%l zlvQU+tZb*;@L1i1FitCa(qdI(dkKGl?ZnA`)nZwxBgg8E&YZK<_ZEr}L3%7G)nW>J z=fcBXA_V+q&oxrhO;}fE5uh7X6j#uB@(Y7ueyc)sC9wsRWZXD$n7LtjO&g^DE1!mX z@B1|`AEA;Ks1zIVcb?fXzT@-mFgUGvPMzq@H2Kxr1YdYF0dzt3lZ5LAe86hkT$_#b zzs{Eo5*lN_d3-}r zg3+aF8m0}lR`J}y8|{ihmDGC1FN;3IBf5{SX|sh`JVQ+Q722}T4LA!x=Avg+{M0PQ zuYfc%uDUyC)yJJM@eQ`G_%BbcRD=W1#W~{=3;Dl9cS$>qR;A9|um-EMJig_3^L|lP zKlzuK151y#NNf7)T_1}ZDO@cbr}s0{q5Se#`kzz;xg%-e#%$S@APVq2hfV@qJX2$w zlz6<1M~aJroOluBuLZ5|!=mY8Z>CX`+#QWuOBNVG;*U{N!_1~hUI0UjFC<0^5= zOl9Qc@93Fkd)IFbbX;mA=G+|!ck@u8p3>Git<&Ur%4?2FV4`Azoc8o%c{JMGS07^> zU%UuFc~eH(nA-n5iU|S4@0osxE(!vD>u$=jLB-*J4vI!WQNkEl%0DWC#RRh7#T&5K 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

    Rqg@p6?=iR&BGn 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 + TDMS diff --git a/web/src/models/IGroup.ts b/web/src/models/IGroup.ts deleted file mode 100644 index 06e2816..0000000 --- a/web/src/models/IGroup.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IGroup { - name: string; - curatorName?: string; - iAmCurator?: boolean; -} \ No newline at end of file diff --git a/web/src/models/authorities.ts b/web/src/models/authorities.ts deleted file mode 100644 index 03148f8..0000000 --- a/web/src/models/authorities.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface IAuthority { - code: string; - name: string; -} - -export const Authorities = Object.freeze({ - TEACHER: {code: "ROLE_TEACHER", name: "Преподаватель"} as IAuthority, - STUDENT: {code: "ROLE_STUDENT", name: "Студент"} as IAuthority, - COMMISSION_MEMBER: {code: "ROLE_COMMISSION_MEMBER", name: "Член комиссии ГЭК"} as IAuthority, - ADMINISTRATOR: {code: "ROLE_ADMINISTRATOR", name: "Администратор"} as IAuthority, - SECRETARY: {code: "ROLE_SECRETARY", name: "Секретарь"} as IAuthority, -}); - -export type Authority = typeof Authorities[keyof typeof Authorities]; -export type AuthorityCode = Authority['code']; -export type AuthorityName = Authority['name']; - -export function getAuthorityByCode(code: AuthorityCode): Authority { - return Object.values(Authorities).find(authority => authority.code === code) as Authority; -} diff --git a/web/src/models/defence.ts b/web/src/models/defence.ts new file mode 100644 index 0000000..d9fb27b --- /dev/null +++ b/web/src/models/defence.ts @@ -0,0 +1,10 @@ +import {Participant} from "./participant"; +import {Group} from "./group"; + +export interface Defence { + id: number; + commissionMembers: Participant[]; + groups: Group[]; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/web/src/models/diplomaTopic.ts b/web/src/models/diplomaTopic.ts new file mode 100644 index 0000000..e17d5eb --- /dev/null +++ b/web/src/models/diplomaTopic.ts @@ -0,0 +1,11 @@ +import {PreparationDirection} from "./preparationDirection"; +import {TeacherData} from "./teacherData"; + +export interface DiplomaTopic { + id?: number; + name: string; + teacher?: TeacherData; + preparationDirection?: PreparationDirection; + createdAt?: string; + updatedAt?: string; +} \ No newline at end of file diff --git a/web/src/models/error.ts b/web/src/models/errorResponse.ts similarity index 100% rename from web/src/models/error.ts rename to web/src/models/errorResponse.ts diff --git a/web/src/models/group.ts b/web/src/models/group.ts new file mode 100644 index 0000000..c0325ac --- /dev/null +++ b/web/src/models/group.ts @@ -0,0 +1,13 @@ +import {StudentData} from "./studentData"; +import {PreparationDirection} from "./preparationDirection"; + +export interface Group { + id?: number; + name?: string; + curatorName?: string; + curatorTeacherId?: number; + students?: StudentData[]; + preparationDirection?: PreparationDirection; + createdAt?: string; + updatedAt?: string; +} diff --git a/web/src/models/participant.ts b/web/src/models/participant.ts new file mode 100644 index 0000000..c23d96e --- /dev/null +++ b/web/src/models/participant.ts @@ -0,0 +1,23 @@ +import {User} from "./user"; +import {Role} from "./roles"; + +export interface Participant { + id: number; + firstName: string; + lastName: string; + middleName: string; + email: string; + numberPhone: string; + user?: User; + roles: Role[]; + createdAt: string; + updatedAt: string; +} + +export const fullName = (participant: Participant): string => { + let fio = ''; + if (participant.lastName) fio += participant.lastName; + if (participant.firstName) fio += ' ' + participant.firstName; + if (participant.middleName) fio += ' ' + participant.middleName; + return fio; +} \ No newline at end of file diff --git a/web/src/models/preparationDirection.ts b/web/src/models/preparationDirection.ts new file mode 100644 index 0000000..dc3d509 --- /dev/null +++ b/web/src/models/preparationDirection.ts @@ -0,0 +1,7 @@ +export interface PreparationDirection { + id: number; + name: string; + code: string; + createdAt?: string; + updatedAt?: string; +} \ No newline at end of file diff --git a/web/src/models/registration.ts b/web/src/models/registration.ts index 65f493f..6b4dc1c 100644 --- a/web/src/models/registration.ts +++ b/web/src/models/registration.ts @@ -1,18 +1,26 @@ -export interface UserRegistrationDTO { - login: string, - password: string, - fullName: string, +import { + RoleCode +} from "./roles"; + +export interface ParticipantSaveDTO { + id?: number, + firstName: string, + lastName: string, + middleName: string, email: string, numberPhone: string, - studentData?: StudentRegistrationDTO - teacherData?: TeacherRegistrationDTO + authorities: RoleCode[], + userData?: { + login: string; + password: string + } + studentData?: { + groupId: number; + curatorId: number + } + teacherData?: { + curatingGroups?: number[]; + advisingStudents?: number[]; + degree?: string + } } - -export interface StudentRegistrationDTO { - groupId: number; -} - -export interface TeacherRegistrationDTO { - curatingGroups: number[]; - advisingStudents: number[]; -} \ No newline at end of file diff --git a/web/src/models/roles.ts b/web/src/models/roles.ts new file mode 100644 index 0000000..d883005 --- /dev/null +++ b/web/src/models/roles.ts @@ -0,0 +1,27 @@ +export const Roles = Object.freeze({ + TEACHER: {code: "ROLE_TEACHER", name: "Преподаватель"}, + STUDENT: {code: "ROLE_STUDENT", name: "Студент"}, + COMMISSION_MEMBER: {code: "ROLE_COMMISSION_MEMBER", name: "Член комиссии ГЭК"}, + ADMINISTRATOR: {code: "ROLE_ADMINISTRATOR", name: "Администратор"}, + SECRETARY: {code: "ROLE_SECRETARY", name: "Секретарь"}, + PLAGIARISM_CHECKER: {code: "ROLE_PLAGIARISM_CHECKER", name: "Проверяющий на плагиат"}, +}); + +export const RolesArray = Object.values(Roles); + +export type Role = typeof Roles[keyof typeof Roles]; +export type RoleCode = Role['code']; +export type RoleName = Role['name']; + +export function getRoleByCode(code: { code: RoleCode; [key: string]: any }): Role { + return Object.values(Roles).find(role => role.code === code.code) as Role; +} + +export function getRoleArrayByCode(codes: Array<{ code: RoleCode; [key: string]: any }>): Role[] { + let onlyCodes = codes.map(code => code.code); + return Object.values(Roles).filter(role => onlyCodes.includes(role.code)); +} + +export function isRolePresent(code: RoleCode, roles: Role[]): boolean { + return roles.some(role => role.code === code); +} \ No newline at end of file diff --git a/web/src/models/student.ts b/web/src/models/student.ts deleted file mode 100644 index cb6d34d..0000000 --- a/web/src/models/student.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {IAuthenticated, IUser} from "./user"; -import {IGroup} from "./IGroup"; - -export interface IStudent { - id: number; - form: boolean; - protectionOrder: number; - magistracy: string; - digitalFormatPresent: boolean; - markComment: number; - markPractice: number; - predefenceComment: string; - normalControl: string; - antiPlagiarism: number; - note: string; - recordBookReturned: boolean; - work: string; - user: IUser; - diplomaTopic: string; - mentorUser: IAuthenticated; - group: IGroup; -} \ No newline at end of file diff --git a/web/src/models/studentData.ts b/web/src/models/studentData.ts new file mode 100644 index 0000000..279729f --- /dev/null +++ b/web/src/models/studentData.ts @@ -0,0 +1,13 @@ +import {Group} from "./group"; +import {Participant} from "./participant"; +import {TeacherData} from "./teacherData"; +import {DiplomaTopic} from "./diplomaTopic"; + +export interface StudentData { + id?: number; + group?: Group; + participant?: Participant; + name?: string; + curator?: TeacherData; + diplomaTopic?: DiplomaTopic; +} \ No newline at end of file diff --git a/web/src/models/teacher.ts b/web/src/models/teacher.ts deleted file mode 100644 index 8d471fc..0000000 --- a/web/src/models/teacher.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {IGroup} from "./IGroup"; -import {IStudent} from "./student"; - -export interface ITeacher { - id: number; - curatingGroups: IGroup[]; - advisingStudents: IStudent[]; - createdAt: string; - updatedAt: string; -} \ No newline at end of file diff --git a/web/src/models/teacherData.ts b/web/src/models/teacherData.ts new file mode 100644 index 0000000..c7535ae --- /dev/null +++ b/web/src/models/teacherData.ts @@ -0,0 +1,9 @@ +import {Participant} from "./participant"; + +export interface TeacherData { + id?: number; + participant?: Participant; + degree?: string; + createdAt?: string; + updatedAt?: string; +} \ No newline at end of file diff --git a/web/src/models/user.ts b/web/src/models/user.ts index 145af20..9d2448e 100644 --- a/web/src/models/user.ts +++ b/web/src/models/user.ts @@ -1,14 +1,9 @@ -export interface IAuthenticated { - id: number, - authenticated: true, - login: string, - fullName: string, - email: string, - phone: string, - authorities: string[], +import {Participant} from "./participant"; - createdAt: string, - updatedAt: string, -} - -export declare type IUser = {authenticated: false} | IAuthenticated; +export interface User { + id: number; + login: string; + participant: Participant; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/web/src/services/NotificationService.ts b/web/src/services/NotificationService.ts index 43eb417..13182a8 100644 --- a/web/src/services/NotificationService.ts +++ b/web/src/services/NotificationService.ts @@ -1,42 +1,93 @@ -import {NotificationStore} from "../store/NotificationStore"; +import {v7 as uuidGen} from "uuid"; +import {action, makeObservable, observable, runInAction} from "mobx"; -export class NotificationService { - static store?: NotificationStore; +export class Notification { + uuid: string; + message: string; + title?: string; - static init(store: NotificationStore) { - this.store = store; - console.debug('NotificationService initialized'); - } - - static error(message: string, title?: string) { - if (!this.store) { - console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`); - return; - } - this.store.error(message, title); - } - - static warn(message: string, title?: string) { - if (!this.store) { - console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`); - return; - } - this.store.warn(message, title); - } - - static info(message: string, title?: string) { - if (!this.store) { - console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`); - return; - } - this.store.info(message, title); - } - - static success(message: string, title?: string) { - if (!this.store) { - console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`); - return; - } - this.store.success(message, title); + constructor(message: string, title?: string) { + this.title = title; + this.message = message; + this.uuid = uuidGen(); } } + +export enum NotificationType { + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', + SUCCESS = 'success' +} + +class NotificationServiceImpl { + @observable errors: Notification[] = []; + @observable warnings: Notification[] = []; + @observable infos: Notification[] = []; + @observable successes: Notification[] = []; + timeout: number = 10000; + + constructor() { + makeObservable(this); + } + + @action.bound + notify(message: string, type: NotificationType, title?: string) { + let notification = new Notification(message, title); + let container: Notification[]; + switch (type) { + case NotificationType.ERROR: + container = this.errors; + break; + case NotificationType.WARNING: + container = this.warnings; + break; + case NotificationType.INFO: + container = this.infos; + break; + case NotificationType.SUCCESS: + container = this.successes; + break; + } + + container.push(notification); + setTimeout(() => runInAction(() => { + this.close(notification.uuid); + }), this.timeout); + } + + @action.bound + error(message: string, title?: string) { + this.notify(message, NotificationType.ERROR, title); + } + + @action.bound + warn(message: string, title?: string) { + this.notify(message, NotificationType.WARNING, title); + } + + @action.bound + info(message: string, title?: string) { + this.notify(message, NotificationType.INFO, title); + } + + @action.bound + success(message: string, title?: string) { + this.notify(message, NotificationType.SUCCESS, title); + } + + @action.bound + close(uuid: string) { + this.errors = this.errors.filter(n => n.uuid !== uuid); + this.warnings = this.warnings.filter(n => n.uuid !== uuid); + this.infos = this.infos.filter(n => n.uuid !== uuid); + this.successes = this.successes.filter(n => n.uuid !== uuid); + } + + @action.bound + accessDenied() { + this.error('Доступ запрещен', 'Ошибка доступа'); + } +} + +export const NotificationService = new NotificationServiceImpl(); diff --git a/web/src/services/RouterService.ts b/web/src/services/RouterService.ts index c466e8e..a11c045 100644 --- a/web/src/services/RouterService.ts +++ b/web/src/services/RouterService.ts @@ -1,6 +1,6 @@ import {AppRouterStore} from "../store/AppRouterStore"; -export interface IRouterOptions { +export interface RouterOptions { redirect: string; } @@ -9,10 +9,9 @@ export class RouterService { static init(router: AppRouterStore) { this.router = router; - console.debug('RouterService initialized'); } - static redirect(state: string, options?: IRouterOptions) { - this.router.goTo(state, options); + static redirect(state: string, options?: RouterOptions) { + this.router.goTo(state, options).then(); } } diff --git a/web/src/store/SysInfoStore.ts b/web/src/services/SysInfoService.ts similarity index 59% rename from web/src/store/SysInfoStore.ts rename to web/src/services/SysInfoService.ts index 5bd9cb7..ceccf9c 100644 --- a/web/src/store/SysInfoStore.ts +++ b/web/src/services/SysInfoService.ts @@ -1,32 +1,31 @@ -import {RootStore} from "./RootStore"; import {action, makeObservable, observable, runInAction} from "mobx"; import {get} from "../utils/request"; +import {ThinkService} from "./ThinkService"; -export class SysInfoStore { - rootStore: RootStore; +class SysInfoServiceImpl { @observable version: string = 'unknown'; - constructor(rootStore: RootStore) { + constructor() { makeObservable(this); - this.rootStore = rootStore; } @action.bound updateVersion() { - this.rootStore.thinkStore.think('updateVersion'); + ThinkService.think('updateVersion'); get('/sysinfo/version').then((response) => { runInAction(() => { this.version = response; }); }).finally(() => { runInAction(() => { - this.rootStore.thinkStore.completeOne('updateVersion'); + ThinkService.completeOne('updateVersion'); }); }); } init() { this.updateVersion(); - console.debug('SysInfoStore initialized'); } -} \ No newline at end of file +} + +export const SysInfoService = new SysInfoServiceImpl(); \ No newline at end of file diff --git a/web/src/store/ThinkStore.ts b/web/src/services/ThinkService.ts similarity index 70% rename from web/src/store/ThinkStore.ts rename to web/src/services/ThinkService.ts index 0d6e7b7..09b17ca 100644 --- a/web/src/store/ThinkStore.ts +++ b/web/src/services/ThinkService.ts @@ -1,12 +1,9 @@ import {action, makeObservable, observable} from "mobx"; -import {RootStore} from "./RootStore"; -export class ThinkStore { - rootStore: RootStore; +class ThinkServiceImpl { @observable thinks: string[] = []; - constructor(rootStore: RootStore) { - this.rootStore = rootStore; + constructor() { makeObservable(this); } @@ -28,8 +25,6 @@ export class ThinkStore { isThinking(key: string = '$default') { return this.thinks.includes(key); } +} - init() { - console.debug('ThinkStore initialized'); - } -} \ No newline at end of file +export const ThinkService = new ThinkServiceImpl(); \ No newline at end of file diff --git a/web/src/services/UserService.ts b/web/src/services/UserService.ts new file mode 100644 index 0000000..2080e77 --- /dev/null +++ b/web/src/services/UserService.ts @@ -0,0 +1,72 @@ +import {action, computed, makeObservable, observable, runInAction} from "mobx"; +import {get} from "../utils/request"; +import {ThinkService} from "./ThinkService"; +import {User} from "../models/user"; +import {RootStore} from "../store/RootStore"; +import {Roles} from "../models/roles"; + +class UserServiceImpl { + @observable user: User; + + constructor() { + makeObservable(this); + } + + @action.bound + updateCurrentUser(callback?: (user: User) => void) { + ThinkService.think('updateCurrentUser'); + get('/user/current', null, true, false).then((response) => { + runInAction(() => { + this.user = response; + }); + }).catch(() => { + runInAction(() => { + this.user = null; + }) + }).finally(() => { + runInAction(() => { + ThinkService.completeOne('updateCurrentUser'); + if (callback) { + callback(this.user); + } + }); + }); + } + + @computed + get isAdministrator() { + return this.user != null && this.user.participant.roles.some(a => a.code === Roles.ADMINISTRATOR.code); + } + + @computed + get isStudent() { + return this.user != null && this.user.participant.roles.some(a => a.code === Roles.STUDENT.code); + } + + @computed + get isTeacher() { + return this.user != null && this.user.participant.roles.some(a => a.code === Roles.TEACHER.code); + } + + @computed + get isCommissionMember() { + return this.user != null && this.user.participant.roles.some(a => a.code === Roles.COMMISSION_MEMBER.code); + } + + @computed + get isSecretary() { + return this.user != null && this.user.participant.roles.some(a => a.code === Roles.SECRETARY.code); + } + + @computed + get authenticated() { + return this.user != null; + } + + @action.bound + init() { + this.updateCurrentUser(); + } +} + +export const UserService = new UserServiceImpl(); \ No newline at end of file diff --git a/web/src/store/AppRouterStore.tsx b/web/src/store/AppRouterStore.tsx index 466e242..40883a2 100644 --- a/web/src/store/AppRouterStore.tsx +++ b/web/src/store/AppRouterStore.tsx @@ -4,7 +4,6 @@ import {RootStore} from "./RootStore"; export class AppRouterStore extends RouterStore { constructor(rootStore: RootStore) { super(routes, createRouterState('error', {notFound: true})); - // makeObservable(this); this.rootStore = rootStore; } @@ -13,7 +12,6 @@ export class AppRouterStore extends RouterStore { init() { const historyAdapter = new HistoryAdapter(this, browserHistory); historyAdapter.observeRouterStateChanges(); - console.debug('MyRouterStore initialized'); return this; } } @@ -22,14 +20,20 @@ const routes: Route[] = [{ name: 'home', pattern: '/', }, { - name: 'profile', - pattern: '/profile', -}, { - name: 'userList', - pattern: '/users', + name: 'participantList', + pattern: '/participants', }, { name: 'groupList', pattern: '/groups', +}, { + name: 'defenceList', + pattern: '/defence', +},{ + name: 'preparationDirectionList', + pattern: '/prep-direction', +},{ + name: 'themeList', + pattern: '/theme', }, { name: 'error', pattern: '/error', diff --git a/web/src/store/NotificationStore.ts b/web/src/store/NotificationStore.ts deleted file mode 100644 index 703e843..0000000 --- a/web/src/store/NotificationStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -import {RootStore} from "./RootStore"; -import {action, makeObservable, observable, runInAction} from "mobx"; -import {v7 as uuidGen} from 'uuid'; - -export class Notification { - uuid: string; - message: string; - title?: string; - - constructor(message: string, title?: string) { - this.title = title; - this.message = message; - this.uuid = uuidGen(); - } -} - -export enum NotificationType { - ERROR = 'error', - WARNING = 'warning', - INFO = 'info', - SUCCESS = 'success' -} - -export class NotificationStore { - rootStore: RootStore; - @observable errors: Notification[] = []; - @observable warnings: Notification[] = []; - @observable infos: Notification[] = []; - @observable successes: Notification[] = []; - timeout: number = 10000; - - constructor(rootStore: RootStore) { - this.rootStore = rootStore; - makeObservable(this); - } - - @action.bound - notify(message: string, type: NotificationType, title?: string) { - let notification = new Notification(message, title); - let container: Notification[]; - switch (type) { - case NotificationType.ERROR: - container = this.errors; - break; - case NotificationType.WARNING: - container = this.warnings; - break; - case NotificationType.INFO: - container = this.infos; - break; - case NotificationType.SUCCESS: - container = this.successes; - break; - } - - container.push(notification); - setTimeout(() => runInAction(() => { - this.close(notification.uuid); - }), this.timeout); - } - - @action.bound - error(message: string, title?: string) { - this.notify(message, NotificationType.ERROR, title); - } - - @action.bound - warn(message: string, title?: string) { - this.notify(message, NotificationType.WARNING, title); - } - - @action.bound - info(message: string, title?: string) { - this.notify(message, NotificationType.INFO, title); - } - - @action.bound - success(message: string, title?: string) { - this.notify(message, NotificationType.SUCCESS, title); - } - - @action.bound - close(uuid: string) { - this.errors = this.errors.filter(n => n.uuid !== uuid); - this.warnings = this.warnings.filter(n => n.uuid !== uuid); - this.infos = this.infos.filter(n => n.uuid !== uuid); - this.successes = this.successes.filter(n => n.uuid !== uuid); - } - - init() { - console.debug('NotificationStore initialized'); - } - - @action.bound - accessDenied() { - this.error('Доступ запрещен', 'Ошибка доступа'); - } -} \ No newline at end of file diff --git a/web/src/store/RootStore.ts b/web/src/store/RootStore.ts index 1ec0639..373e800 100644 --- a/web/src/store/RootStore.ts +++ b/web/src/store/RootStore.ts @@ -1,27 +1,14 @@ import {AppRouterStore} from "./AppRouterStore"; -import {UserStore} from "./UserStore"; -import {ThinkStore} from "./ThinkStore"; -import {NotificationStore} from "./NotificationStore"; -import {SysInfoStore} from "./SysInfoStore"; export class RootStore { - thinkStore = new ThinkStore(this); - userStore = new UserStore(this); routerStore = new AppRouterStore(this); - notificationStore = new NotificationStore(this); - sysInfoStore = new SysInfoStore(this); constructor() { } init() { - this.thinkStore.init(); this.routerStore.init(); - this.notificationStore.init(); - this.sysInfoStore.init(); - this.userStore.init(); - console.debug('RootStore initialized'); return this; } } diff --git a/web/src/store/UserStore.ts b/web/src/store/UserStore.ts deleted file mode 100644 index 8b00b06..0000000 --- a/web/src/store/UserStore.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {get} from "../utils/request"; -import {action, computed, makeObservable, observable, runInAction} from "mobx"; -import {RootStore} from "./RootStore"; -import type {IUser} from "../models/user"; -import {IStudent} from "../models/student"; -import {Authorities} from "../models/authorities"; - -export class UserStore { - rootStore: RootStore; - @observable user: IUser = {authenticated: false}; - @observable student: IStudent; - - constructor(rootStore: RootStore) { - makeObservable(this); - this.rootStore = rootStore; - } - - @action.bound - updateCurrentUser(callback?: (user: IUser) => void) { - this.rootStore.thinkStore.think('updateCurrentUser'); - get('/user/current').then((response) => { - runInAction(() => { - this.user = response; - }); - if (this.isStudent) { - get('/student/current').then((student) => { - runInAction(() => { - this.student = student; - }); - }); - } - }).finally(() => { - runInAction(() => { - this.rootStore.thinkStore.completeOne('updateCurrentUser'); - if (callback) { - callback(this.user); - } - }); - }); - } - - @computed - get isAdministrator() { - return this.user.authenticated && this.user.authorities.some(a => a === Authorities.ADMINISTRATOR.code); - } - - @computed - get isStudent() { - return this.user.authenticated && this.user.authorities.some(a => a === Authorities.STUDENT.code); - } - - @computed - get isTeacher() { - return this.user.authenticated && this.user.authorities.some(a => a === Authorities.TEACHER.code); - } - - @computed - get isCommissionMember() { - return this.user.authenticated && this.user.authorities.some(a => a === Authorities.COMMISSION_MEMBER.code); - } - - @computed - get isSecretary() { - return this.user.authenticated && this.user.authorities.some(a => a === Authorities.SECRETARY.code); - } - - @computed - get authenticated() { - return this.user.authenticated - } - - init() { - this.updateCurrentUser(); - console.debug('UserStore initialized'); - return this; - } -} diff --git a/web/src/utils/ComponentContext.ts b/web/src/utils/ComponentContext.ts index 25afb43..963488a 100644 --- a/web/src/utils/ComponentContext.ts +++ b/web/src/utils/ComponentContext.ts @@ -9,23 +9,7 @@ export abstract class ComponentContext

    extends Compone return this.context; } - get thinkStore() { - return this.context.thinkStore; - } - - get userStore() { - return this.context.userStore; - } - get routerStore() { return this.context.routerStore; } - - get notificationStore() { - return this.context.notificationStore; - } - - get sysInfoStore() { - return this.context.sysInfoStore; - } } \ No newline at end of file diff --git a/web/src/utils/init.ts b/web/src/utils/init.ts index a63510f..f9e9ff6 100644 --- a/web/src/utils/init.ts +++ b/web/src/utils/init.ts @@ -1,47 +1,53 @@ -import {configure} from "mobx"; -import {RootStore} from "../store/RootStore"; -import {RouterService} from "../services/RouterService"; -import {library} from '@fortawesome/fontawesome-svg-core'; -import {fas} from '@fortawesome/free-solid-svg-icons'; -import {fab} from "@fortawesome/free-brands-svg-icons"; -import {far} from "@fortawesome/free-regular-svg-icons"; -import {NotificationService} from "../services/NotificationService"; +import { + configure +} from "mobx"; +import { + RootStore +} from "../store/RootStore"; +import { + RouterService +} from "../services/RouterService"; +import { + library +} from '@fortawesome/fontawesome-svg-core'; +import { + fas +} from '@fortawesome/free-solid-svg-icons'; +import { + fab +} from "@fortawesome/free-brands-svg-icons"; +import { + far +} from "@fortawesome/free-regular-svg-icons"; +import { + SysInfoService +} from "../services/SysInfoService"; +import {UserService} from "../services/UserService"; const initMobX = () => { configure({enforceActions: 'observed'}); - console.debug('MobX initialized'); } const initFontAwesome = () => { library.add(fas); library.add(fab); library.add(far); - console.debug('FontAwesome initialized'); } const initLibs = () => { initMobX(); initFontAwesome(); - console.debug('Libraries initialized'); } const initServices = (rootStore: RootStore) => { RouterService.init(rootStore.routerStore); - NotificationService.init(rootStore.notificationStore); - console.debug('Services initialized'); + SysInfoService.init(); + UserService.init(); } export const initApp = () => { - console.debug('Initializing app'); - console.debug('>>>>>>>>>>>>>>>>>>>>>>>>'); - initLibs(); - let rootStore = new RootStore().init(); - initServices(rootStore); - - console.debug('<<<<<<<<<<<<<<<<<<<<<<<<'); - console.debug('App initialized'); return rootStore; } \ No newline at end of file diff --git a/web/src/utils/modalState.ts b/web/src/utils/modalState.ts index 15c959e..feb5973 100644 --- a/web/src/utils/modalState.ts +++ b/web/src/utils/modalState.ts @@ -2,10 +2,12 @@ import {action, makeObservable, observable} from "mobx"; export class ModalState { @observable isOpen: boolean; + @observable onApply?: () => void; - constructor(isOpen: boolean = false) { + constructor(isOpen: boolean = false, onApply?: () => void) { makeObservable(this); this.isOpen = isOpen; + this.onApply = onApply; } @action.bound diff --git a/web/src/utils/reactive/reactiveValue.ts b/web/src/utils/reactive/reactiveValue.ts index 5d3f839..193a81e 100644 --- a/web/src/utils/reactive/reactiveValue.ts +++ b/web/src/utils/reactive/reactiveValue.ts @@ -80,7 +80,7 @@ export class ReactiveValue { @action.bound touch() { - this.isTouched = true; + this.set(this.value); } @action.bound diff --git a/web/src/utils/reactive/validators.ts b/web/src/utils/reactive/validators.ts index de6f5a9..e8b1282 100644 --- a/web/src/utils/reactive/validators.ts +++ b/web/src/utils/reactive/validators.ts @@ -1,4 +1,4 @@ -import {ReactiveSelectInputSelect} from "../../components/custom/controls/ReactiveControls"; +import {SelectInputValue} from "../../components/controls/ReactiveControls"; export const required = (value: any, field = 'Поле') => { const message = `${field} обязательно для заполнения`; @@ -11,7 +11,7 @@ export const required = (value: any, field = 'Поле') => { } } -export const selected = (value: ReactiveSelectInputSelect, field = 'Поле') => { +export const selected = (value: SelectInputValue, field = 'Поле') => { if (!value.value || value.value === '__unselected__') { return `${field} должно быть выбрано`; } @@ -121,14 +121,14 @@ export const passwordChars = (value: string, field = 'Поле') => { } export const nameChars = (value: string, field = 'Поле') => { - if (!/^[a-zA-Zа-яА-ЯёЁ\s]+$/.test(value)) { - return `${field} должно содержать только буквы английского или русского алфавита и пробелы`; + if (!/^[a-zA-Zа-яА-ЯёЁ\s-]+$/.test(value)) { + return `${field} должно содержать только буквы английского или русского алфавита, пробелы и дефис`; } } export const nameLength = (value: string, field = 'Поле') => { - if (value.length < 3) { - return `${field} должно содержать минимум 3 символа`; + if (value.length < 1) { + return `${field} должно содержать минимум 1 символ`; } } diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 63b925d..ed9205f 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -1,6 +1,6 @@ import axios, {AxiosError, AxiosRequestConfig} from "axios"; import {NotificationService} from "../services/NotificationService"; -import {ErrorResponse} from "../models/error"; +import {ErrorResponse} from "../models/errorResponse"; export const apiUrl = "http://localhost:8080/api/v1/"; @@ -8,6 +8,9 @@ export const get = async (url: string, data?: any, doReject = true, showError url: url, method: 'GET', params: data, + headers: { + Accept: 'application/json', + } } as AxiosRequestConfig, doReject, showError); export const post = async (url: string, data?: any, doReject = true, showError = true) => await request({ @@ -16,7 +19,7 @@ export const post = async (url: string, data?: any, doReject = true, showErro data: data, } as AxiosRequestConfig, doReject, showError); -export const request = async (config: AxiosRequestConfig, doReject: boolean, showError: boolean) => { +export const request = async (config: AxiosRequestConfig, doReject: boolean = true, showError: boolean) => { return new Promise((resolve, reject) => { axios.request({...config, baseURL: apiUrl, withCredentials: true}).then((response) => { resolve(response.data); @@ -26,7 +29,6 @@ export const request = async (config: AxiosRequestConfig, doReject: bool else if (error.request) NotificationService.error(error.request, 'Не удалось получить ответ от сервера'); else NotificationService.error(error.message, 'Запрос отправить не удалось'); } - if (doReject) reject(error); }); }); diff --git a/web/src/utils/tables.ts b/web/src/utils/tables.ts index b6a26ca..136c383 100644 --- a/web/src/utils/tables.ts +++ b/web/src/utils/tables.ts @@ -15,7 +15,7 @@ export class TableDescriptor { columns: Column[], data: R[], pageable = true, - filters: ((row: R) => boolean)[] = [() => true] + filters: ((row: R) => boolean)[] = [() => true], ) { makeObservable(this); this.columns = columns; @@ -26,7 +26,8 @@ export class TableDescriptor { } export class Column { - @observable key: string; /* key of the field in the data object */ + @observable key: string; + @observable renderKey: string; @observable title: string; @observable sort: Sort; @observable suffixElement?: (data: R) => ReactNode; @@ -34,16 +35,17 @@ export class Column { constructor( key: string, title: string, - format: (value: C, data: R) => ReactNode = (value: C) => value ? _.toString(value) : 'Пусто', + format?: (value: C, data: R) => ReactNode, suffix?: (data: R) => ReactNode, - sort: Sort = new Sort() + sort?: Sort ) { makeObservable(this); - this.suffixElement = suffix; this.key = key; + this.renderKey = _.uniqueId(this.key); this.title = title; - this.format = format; - this.sort = sort; + this.suffixElement = suffix; + this.format = format ?? (v => v ? _.toString(v) : "Пусто"); + this.sort = sort ?? new Sort(); } } diff --git a/web/webpack.config.js b/web/webpack.config.js index 9ffde96..902143e 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -1,5 +1,6 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { entry: { @@ -20,6 +21,9 @@ module.exports = { }, { test: /\.(png|jpe?g|gif|svg)$/i, type: 'asset/resource', + generator: { + filename: '[name][ext]' + } }, { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', @@ -31,6 +35,7 @@ module.exports = { filename: '[name].[contenthash].js', path: path.resolve(__dirname, 'dist'), clean: true, + publicPath: '/' }, optimization: { splitChunks: { @@ -53,6 +58,11 @@ module.exports = { plugins: [ new HtmlWebpackPlugin({ template: path.join(__dirname, 'src', 'index.html') + }), + new CopyWebpackPlugin({ + patterns: [ + { from: 'src/favicon.png', to: 'favicon.png' } + ] }) ] }