GroupCreation, ErrorHandling, Database

This commit is contained in:
Maksim Skobaro 2025-02-07 09:48:50 +03:00
parent c2d19a7724
commit b1ea3a670e
31 changed files with 369 additions and 154 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
/logs/app.log /logs/app.log
/configurations/Start RDBMS.run.xml

View File

@ -97,6 +97,7 @@ public class SecurityConfiguration {
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll(); httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
/* GroupController */ /* GroupController */
httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll(); httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll();
httpAuthorization.requestMatchers("api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
/* deny all other api requests */ /* deny all other api requests */
httpAuthorization.requestMatchers("/api/**").denyAll(); httpAuthorization.requestMatchers("/api/**").denyAll();
/* since api already blocked, all other requests are static resources */ /* since api already blocked, all other requests are static resources */

View File

@ -1,14 +1,16 @@
package ru.tubryansk.tdms.controller; package ru.tubryansk.tdms.controller;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.controller.payload.GroupDTO; import ru.tubryansk.tdms.controller.payload.GroupDTO;
import ru.tubryansk.tdms.controller.payload.GroupRegistrationDTO;
import ru.tubryansk.tdms.service.GroupService; import ru.tubryansk.tdms.service.GroupService;
import java.util.Collection; import java.util.Collection;
@RestController("/api/v1/group/") @RestController
@RequestMapping("/api/v1/group")
public class GroupController { public class GroupController {
@Autowired @Autowired
private GroupService groupService; private GroupService groupService;
@ -17,4 +19,9 @@ public class GroupController {
public Collection<GroupDTO> getAllGroups() { public Collection<GroupDTO> getAllGroups() {
return groupService.getAllGroups(); return groupService.getAllGroups();
} }
@PostMapping("/create-group")
public void createGroup(@RequestBody @Valid GroupRegistrationDTO groupRegistrationDTO) {
groupService.createGroup(groupRegistrationDTO.getName());
}
} }

View File

@ -1,6 +1,5 @@
package ru.tubryansk.tdms.controller; package ru.tubryansk.tdms.controller;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -13,9 +12,10 @@ public class SysInfoController {
@Autowired @Autowired
private SysInfoService sysInfoService; private SysInfoService sysInfoService;
@SneakyThrows
@GetMapping("/version") @GetMapping("/version")
public String getVersion() { public String getVersion() {
return sysInfoService.getVersion(); return sysInfoService.getVersion();
} }
// @GetMapping("/status")
} }

View File

@ -9,6 +9,6 @@ import lombok.ToString;
@ToString @ToString
public class GroupDTO { public class GroupDTO {
private String name; private String name;
private String principalName; private String curatorName;
private Boolean isMePrincipal; private Boolean iAmCurator;
} }

View File

@ -0,0 +1,14 @@
package ru.tubryansk.tdms.controller.payload;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
@Getter
public class GroupRegistrationDTO {
@NotEmpty(message = "Имя группы не может быть пустым")
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
private String name;
}

View File

@ -5,13 +5,17 @@ import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.ZonedDateTime;
@Getter @Getter
@Setter @Setter
@ToString @ToString
@Entity @Entity
@Table(name = "group") @Table(name = "`group`")
public class Group { public class Group {
@Id @Id
@Column(name = "id") @Column(name = "id")
@ -20,6 +24,12 @@ public class Group {
@Column(name = "name") @Column(name = "name")
private String name; private String name;
@ManyToOne @ManyToOne
@JoinColumn(name = "curator_user_id") @JoinColumn(name = "curator_teacher_id")
private User groupCurator; private Teacher groupCurator;
@Column(name = "created_at")
@CreationTimestamp
private ZonedDateTime createdAt;
@Column(name = "updated_at")
@UpdateTimestamp
private ZonedDateTime updatedAt;
} }

View File

@ -47,9 +47,10 @@ public class Student {
@ManyToOne @ManyToOne
@JoinColumn(name = "diploma_topic_id") @JoinColumn(name = "diploma_topic_id")
private DiplomaTopic diplomaTopic; private DiplomaTopic diplomaTopic;
// Научный руководитель
@ManyToOne @ManyToOne
@JoinColumn(name = "mentor_user_id") @JoinColumn(name = "adviser_teacher_id")
private User mentorUser; private Teacher adviser;
@ManyToOne @ManyToOne
@JoinColumn(name = "group_id") @JoinColumn(name = "group_id")
private Group group; private Group group;

View File

@ -0,0 +1,32 @@
package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.Getter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.ZonedDateTime;
import java.util.List;
@Getter
@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<Group> curatingGroups;
@OneToMany(mappedBy = "adviser")
private List<Student> advisingStudents;
@Column(name = "created_at")
@CreationTimestamp
private ZonedDateTime createdAt;
@Column(name = "updated_at")
@UpdateTimestamp
private ZonedDateTime updatedAt;
}

View File

@ -2,6 +2,7 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
@ -19,6 +20,7 @@ import java.util.List;
@Getter @Getter
@Setter @Setter
@ToString @ToString
@EqualsAndHashCode(of = "id")
@Entity @Entity
@Table(name = "`user`") @Table(name = "`user`")
public class User implements UserDetails { public class User implements UserDetails {

View File

@ -10,4 +10,6 @@ public interface GroupRepository extends JpaRepository<Group, Long> {
default Group findByIdThrow(Long id) { default Group findByIdThrow(Long id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(Group.class, id)); return this.findById(id).orElseThrow(() -> new NotFoundException(Group.class, id));
} }
boolean existsByName(String name);
} }

View File

@ -9,8 +9,8 @@ import ru.tubryansk.tdms.exception.NotFoundException;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface StudentRepository extends JpaRepository<Student, Integer> { public interface StudentRepository extends JpaRepository<Student, Long> {
default Student findByIdThrow(Integer id) { default Student findByIdThrow(Long id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(Student.class, id)); return this.findById(id).orElseThrow(() -> new NotFoundException(Student.class, id));
} }

View File

@ -0,0 +1,13 @@
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.tubryansk.tdms.entity.Teacher;
import ru.tubryansk.tdms.exception.NotFoundException;
@Repository
public interface TeacherRepository extends JpaRepository<Teacher, Long> {
default Teacher findByIdThrow(Long id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(Teacher.class, id));
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
import ru.tubryansk.tdms.controller.payload.ErrorResponse; import ru.tubryansk.tdms.controller.payload.ErrorResponse;
import java.util.Random;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
@ -51,7 +52,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpectedException(Exception e) { public ErrorResponse handleUnexpectedException(Exception e) {
log.error("Unexpected exception.", e); Random random = new Random();
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.INTERNAL_ERROR); long errorInx = random.nextLong();
log.error("Unexpected exception. random: {}", errorInx, e);
return new ErrorResponse("Произошла непредвиденная ошибка, обратитесь к администратору. Номер ошибки: " + errorInx, ErrorResponse.ErrorCode.INTERNAL_ERROR);
} }
} }

View File

@ -1,15 +1,53 @@
package ru.tubryansk.tdms.service; package ru.tubryansk.tdms.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import ru.tubryansk.tdms.controller.payload.GroupDTO; import ru.tubryansk.tdms.controller.payload.GroupDTO;
import ru.tubryansk.tdms.entity.Group;
import ru.tubryansk.tdms.entity.User;
import ru.tubryansk.tdms.entity.repository.GroupRepository;
import ru.tubryansk.tdms.exception.BusinessException;
import java.util.Collection; import java.util.Collection;
import java.util.List;
@Service @Service
@Transactional @Transactional
public class GroupService { public class GroupService {
@Autowired
private GroupRepository groupRepository;
@Autowired
private CallerService callerService;
public Collection<GroupDTO> getAllGroups() { public Collection<GroupDTO> getAllGroups() {
return null; List<Group> groups = groupRepository.findAll();
User callerUser = callerService.getCallerUser().orElse(null);
return groups.stream()
.map(g -> {
GroupDTO groupDTO = new GroupDTO();
groupDTO.setName(g.getName());
if (g.getGroupCurator() != null) {
groupDTO.setCuratorName(g.getGroupCurator().getUser().getFullName());
if (callerUser != null) {
groupDTO.setIAmCurator(g.getGroupCurator().getUser().equals(callerUser));
}
}
return groupDTO;
})
.toList();
}
public void createGroup(String groupName) {
boolean existsByName = groupRepository.existsByName(groupName);
if (existsByName) {
throw new BusinessException("Группа с именем " + groupName + " уже существует");
}
Group group = new Group();
group.setName(groupName);
groupRepository.save(group);
} }
} }

View File

@ -10,7 +10,6 @@ import ru.tubryansk.tdms.entity.repository.DiplomaTopicRepository;
import ru.tubryansk.tdms.entity.repository.StudentRepository; import ru.tubryansk.tdms.entity.repository.StudentRepository;
import ru.tubryansk.tdms.exception.AccessDeniedException; import ru.tubryansk.tdms.exception.AccessDeniedException;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@Service @Service
@ -26,15 +25,15 @@ public class StudentService {
private CallerService callerService; private CallerService callerService;
/** @param studentToDiplomaTopic Map of @{@link Student} id and @{@link DiplomaTopic} id */ /** @param studentToDiplomaTopic Map of @{@link Student} id and @{@link DiplomaTopic} id */
public void changeDiplomaTopic(Map<Integer, Integer> studentToDiplomaTopic) { // public void changeDiplomaTopic(Map<Integer, Integer> studentToDiplomaTopic) {
studentToDiplomaTopic.forEach(this::changeDiplomaTopic); // studentToDiplomaTopic.forEach(this::changeDiplomaTopic);
} // }
public void changeDiplomaTopic(Integer studentId, Integer diplomaTopicId) { // public void changeDiplomaTopic(Integer studentId, Integer diplomaTopicId) {
Student student = studentRepository.findByIdThrow(studentId); // Student student = studentRepository.findByIdThrow(studentId);
DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId); // DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);
student.setDiplomaTopic(diplomaTopic); // student.setDiplomaTopic(diplomaTopic);
} // }
public void changeCallerDiplomaTopic(Integer diplomaTopicId) { public void changeCallerDiplomaTopic(Integer diplomaTopicId) {
DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId); DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);

View File

@ -1,22 +0,0 @@
create table "group"
(
id bigserial primary key,
name text not null unique,
curator_user_id bigint,
created_at timestamptz not null,
updated_at timestamptz
);
-- FOREIGN KEY
alter table "group"
add constraint fk_group_curator_user_id
foreign key (curator_user_id) references "user" (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_user_id is 'Идентификатор куратора группы';

View File

@ -0,0 +1,19 @@
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 'Идентификатор пользователя';

View File

@ -0,0 +1,22 @@
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 'Идентификатор куратора группы';

View File

@ -1,18 +1,18 @@
create table student create table student
( (
id bigserial primary key, id bigserial primary key,
user_id bigint not null, user_id bigint not null,
diploma_topic_id bigint not null, diploma_topic_id bigint not null,
mentor_user_id bigint not null, adviser_teacher_id bigint not null,
group_id bigint not null, group_id bigint not null,
form boolean, form boolean,
protection_day int, protection_day int,
protection_order int, protection_order int,
magistracy text, magistracy text,
digital_format_present boolean, digital_format_present boolean,
mark_comment int, mark_comment int,
mark_practice int, mark_practice int,
predefence_comment text, predefence_comment text,
normal_control text, normal_control text,
anti_plagiarism int, anti_plagiarism int,
@ -34,8 +34,8 @@ alter table student
foreign key (diploma_topic_id) references diploma_topic (id) foreign key (diploma_topic_id) references diploma_topic (id)
on delete set null on update cascade; on delete set null on update cascade;
alter table student alter table student
add constraint fk_student_mentor_user_id add constraint fk_student_adviser_teacher_id
foreign key (mentor_user_id) references "user" (id) foreign key (adviser_teacher_id) references teacher (id)
on delete set null on update cascade; on delete set null on update cascade;
alter table student alter table student
add constraint fk_student_group_id add constraint fk_student_group_id
@ -47,7 +47,7 @@ comment on table student is 'Таблица студентов';
comment on column student.user_id is 'Идентификатор пользователя'; comment on column student.user_id is 'Идентификатор пользователя';
comment on column student.diploma_topic_id is 'Идентификатор темы дипломной работы'; comment on column student.diploma_topic_id is 'Идентификатор темы дипломной работы';
comment on column student.mentor_user_id is 'Идентификатор научного руководителя'; comment on column student.adviser_teacher_id is 'Идентификатор научного руководителя';
comment on column student.group_id is 'Идентификатор группы'; comment on column student.group_id is 'Идентификатор группы';
comment on column student.form is 'Форма обучения'; comment on column student.form is 'Форма обучения';

View File

@ -1,7 +1,7 @@
import {ComponentContext} from "../utils/ComponentContext"; import {ComponentContext} from "../utils/ComponentContext";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {Notification, NotificationType} from "../store/NotificationStore"; import {Notification, NotificationType} from "../store/NotificationStore";
import {Card, CardBody, CardHeader, CardText, CardTitle, Col, Row} from "react-bootstrap"; import {Card, CardBody, CardHeader, CardText, CardTitle} from "react-bootstrap";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {action, makeObservable} from "mobx"; import {action, makeObservable} from "mobx";
@ -14,7 +14,7 @@ export class NotificationContainer extends ComponentContext {
} }
render() { render() {
return <div style={{position: 'fixed', left: '50%', transform: 'translateX(-50%)', zIndex: 1000}}> return <div style={{position: 'fixed', left: '50%', transform: 'translateX(-50%)', zIndex: 2000}}>
{this.forEachNotificationRender(this.notificationStore.errors, NotificationType.ERROR)} {this.forEachNotificationRender(this.notificationStore.errors, NotificationType.ERROR)}
{this.forEachNotificationRender(this.notificationStore.successes, NotificationType.SUCCESS)} {this.forEachNotificationRender(this.notificationStore.successes, NotificationType.SUCCESS)}
{this.forEachNotificationRender(this.notificationStore.warnings, NotificationType.WARNING)} {this.forEachNotificationRender(this.notificationStore.warnings, NotificationType.WARNING)}
@ -51,37 +51,25 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t
render() { render() {
const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0; const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0;
const closeIcon = <FontAwesomeIcon icon={'close'} onClick={this.close}/>; const closeIcon = <span className={'ms-2'}><FontAwesomeIcon icon={'close'} onClick={this.close}/></span>;
return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}> return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}>
{ {
hasTitle && hasTitle &&
<CardHeader> <CardHeader>
<CardTitle> <CardTitle className={'d-flex justify-content-between align-items-start'}>
<Row> {this.props.notification.title}
<Col sm={11}> {closeIcon}
{this.props.notification.title}
</Col>
<Col className={'text-end'}>
{closeIcon}
</Col>
</Row>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
} }
<CardBody> <CardBody>
<CardText> <CardText className={'d-flex justify-content-between align-items-start'}>
<Row> {this.props.notification.message}
<Col sm={11}> {
{this.props.notification.message} !hasTitle &&
</Col> closeIcon
{ }
!hasTitle &&
<Col className={'text-end'}>
{closeIcon}
</Col>
}
</Row>
</CardText> </CardText>
</CardBody> </CardBody>
</Card> </Card>

View File

@ -74,7 +74,7 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`} className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
onChange={this.onChange} value={this.props.value.value}/> onChange={this.onChange} value={this.props.value.value}/>
</FloatingLabel> </FloatingLabel>
<Button onClick={this.toggleShowPassword} variant={"outline-secondary"}> <Button onClick={this.toggleShowPassword} variant={"outline-secondary"} className={'ms-2'}>
<FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/> <FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/>
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,55 @@
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 CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
@observable name = new ReactiveValue<string>()
.addValidator(required)
.addValidator(strLength(3, 50))
.addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры"));
constructor(props: any) {
super(props);
makeObservable(this);
}
@action.bound
creationRequest() {
post<void>('/group/create-group', {name: this.name.value}).then(() => {
this.notificationStore.success(`Группа ${this.name.value} создана`);
}).finally(() => {
this.props.modalState.close();
});
}
@computed
get formInvalid() {
return this.name.invalid || !this.name.touched;
}
render() {
return <Modal show={this.props.modalState.isOpen}>
<ModalHeader>
<ModalTitle>Создание группы</ModalTitle>
</ModalHeader>
<ModalBody>
<StringInput value={this.name} label={'Имя группы'}/>
</ModalBody>
<ModalFooter>
<Button onClick={this.creationRequest} disabled={this.formInvalid}>Создать</Button>
<Button onClick={this.props.modalState.close}>Закрыть</Button>
</ModalFooter>
</Modal>
}
}

View File

@ -6,14 +6,18 @@ import {observer} from "mobx-react";
import {post} from "../../utils/request"; import {post} from "../../utils/request";
import {LoginModal} from "../user/LoginModal"; import {LoginModal} from "../user/LoginModal";
import {ModalState} from "../../utils/modalState"; import {ModalState} from "../../utils/modalState";
import {action, makeObservable} from "mobx"; import {action, makeObservable, observable} from "mobx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../utils/ComponentContext";
import {CreateGroupModal} from "../group/CreateGroupModal";
import {UserRegistrationModal} from "../user/UserRegistrationModal";
@observer @observer
class Header extends ComponentContext { class Header extends ComponentContext {
loginModalState = new ModalState(); @observable loginModalState = new ModalState();
@observable createGroupModalState = new ModalState();
@observable userRegistrationModalState = new ModalState();
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -37,10 +41,17 @@ class Header extends ComponentContext {
user.authenticated && userStore.isAdministrator() && user.authenticated && userStore.isAdministrator() &&
<NavDropdown title="Пользователи"> <NavDropdown title="Пользователи">
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/> <NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
<NavDropdown.Item as={RouterLink} routeName={'userRegistration'} <NavDropdown.Item onClick={this.userRegistrationModalState.open}
children={'Зарегистрировать'}/> children={'Зарегистрировать'}/>
</NavDropdown> </NavDropdown>
} }
{
user.authenticated && userStore.isAdministrator() &&
<NavDropdown title="Группы">
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
<NavDropdown.Item onClick={this.createGroupModalState.open} children={'Добавить'}/>
</NavDropdown>
}
</Nav> </Nav>
<Nav className="ms-auto"> <Nav className="ms-auto">
@ -63,6 +74,8 @@ class Header extends ComponentContext {
</Navbar> </Navbar>
</header> </header>
<LoginModal modalState={this.loginModalState}/> <LoginModal modalState={this.loginModalState}/>
<CreateGroupModal modalState={this.createGroupModalState}/>
<UserRegistrationModal modalState={this.userRegistrationModalState}/>
</> </>
} }
} }

View File

@ -1,7 +1,6 @@
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {DefaultPage} from "../layout/DefaultPage";
import {action, computed, makeObservable, observable} from "mobx"; import {action, computed, makeObservable, observable} from "mobx";
import {Button, Col, Form, Row} from "react-bootstrap"; import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row} from "react-bootstrap";
import {UserRegistrationDTO} from "../../models/registration"; import {UserRegistrationDTO} from "../../models/registration";
import {post} from "../../utils/request"; import {post} from "../../utils/request";
import {ReactiveValue} from "../../utils/reactive/reactiveValue"; import {ReactiveValue} from "../../utils/reactive/reactiveValue";
@ -17,9 +16,15 @@ import {
phone, phone,
required required
} from "../../utils/reactive/validators"; } from "../../utils/reactive/validators";
import {ComponentContext} from "../../utils/ComponentContext";
import {ModalState} from "../../utils/modalState";
export interface UserRegistrationModalProps {
modalState: ModalState;
}
@observer @observer
export class UserRegistration extends DefaultPage { export class UserRegistrationModal extends ComponentContext<UserRegistrationModalProps> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
makeObservable(this); makeObservable(this);
@ -31,7 +36,6 @@ export class UserRegistration extends DefaultPage {
@observable email = new ReactiveValue<string>().addValidator(required).addValidator(email); @observable email = new ReactiveValue<string>().addValidator(required).addValidator(email);
@observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7'); @observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7');
@observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => { @observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
if (!['student', 'admin'].includes(value)) { if (!['student', 'admin'].includes(value)) {
return 'Тип аккаунта должен быть "СТУДЕНТ" или "АДМИНИСТРАТОР"'; return 'Тип аккаунта должен быть "СТУДЕНТ" или "АДМИНИСТРАТОР"';
@ -64,9 +68,13 @@ export class UserRegistration extends DefaultPage {
}); });
} }
get page() { render() {
return <div className={'w-75 ms-auto me-auto'}> return <Modal show={this.props.modalState.isOpen} size={'lg'}>
<Form> <ModalHeader>
<ModalTitle>Регистрация пользователя</ModalTitle>
</ModalHeader>
<ModalBody>
<Row> <Row>
<Col> <Col>
<StringInput value={this.login} label={"Логин"}/> <StringInput value={this.login} label={"Логин"}/>
@ -78,10 +86,12 @@ export class UserRegistration extends DefaultPage {
<StringInput value={this.numberPhone} label={"Телефон"}/> <StringInput value={this.numberPhone} label={"Телефон"}/>
</Col> </Col>
</Row> </Row>
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/> <SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/>
</ModalBody>
<ModalFooter>
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button> <Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
</Form> <Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
</div> </ModalFooter>
</Modal>
} }
} }

12
web/src/models/error.ts Normal file
View File

@ -0,0 +1,12 @@
export enum ErrorCode {
BUSINESS_ERROR = 'BUSINESS_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
INTERNAL_ERROR = 'INTERNAL_ERROR',
NOT_FOUND = 'NOT_FOUND',
ACCESS_DENIED = 'ACCESS_DENIED',
}
export interface ErrorResponse {
message: string;
errorCode: ErrorCode;
}

View File

@ -9,9 +9,6 @@ export const routes: Route[] = [{
}, { }, {
name: 'userList', name: 'userList',
pattern: '/users', pattern: '/users',
}, {
name: 'userRegistration',
pattern: '/user-registration',
}, { }, {
name: 'error', name: 'error',
pattern: '/error', pattern: '/error',

View File

@ -3,12 +3,10 @@ import Home from "../components/layout/Home";
import Error from "../components/layout/Error"; import Error from "../components/layout/Error";
import UserProfilePage from "../components/user/UserProfilePage"; import UserProfilePage from "../components/user/UserProfilePage";
import {UserList} from "../components/user/UserList"; import {UserList} from "../components/user/UserList";
import {UserRegistration} from "../components/user/UserRegistration";
export const viewMap: ViewMap = { export const viewMap: ViewMap = {
root: <Home/>, root: <Home/>,
profile: <UserProfilePage/>, profile: <UserProfilePage/>,
userList: <UserList/>, userList: <UserList/>,
userRegistration: <UserRegistration/>,
error: <Error/>, error: <Error/>,
} }

View File

@ -1,6 +1,4 @@
import {action, computed, makeObservable, observable, reaction} from "mobx"; import {action, computed, makeObservable, observable, reaction} from "mobx";
import React from "react";
import _ from "lodash";
export class ReactiveValue<T> { export class ReactiveValue<T> {
@ -95,33 +93,33 @@ export class ReactiveValue<T> {
} }
} }
export class NumberField extends ReactiveValue<number> { // export class NumberField extends ReactiveValue<number> {
constructor(fireImmediately: boolean = false) { // constructor(fireImmediately: boolean = false) {
super(fireImmediately); // super(fireImmediately);
makeObservable(this); // makeObservable(this);
this.addValidator(value => { // this.addValidator(value => {
if (_.isNaN(value)) { // if (_.isNaN(value)) {
return 'Должно быть числом'; // return 'Должно быть числом';
} // }
//
return null; // return null;
}); // });
} // }
//
@action.bound // @action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) { // onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.set(_.toNumber(event.currentTarget.value)); // this.set(_.toNumber(event.currentTarget.value));
} // }
} // }
//
export class BooleanField extends ReactiveValue<boolean> { // export class BooleanField extends ReactiveValue<boolean> {
constructor(fireImmediately: boolean = false) { // constructor(fireImmediately: boolean = false) {
super(fireImmediately); // super(fireImmediately);
makeObservable(this); // makeObservable(this);
} // }
//
@action.bound // @action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) { // onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.set(event.currentTarget.checked); // this.set(event.currentTarget.checked);
} // }
} // }

View File

@ -3,17 +3,6 @@ export const required = (value: any, field = 'Поле') => {
return `${field} обязательно для заполнения`; return `${field} обязательно для заполнения`;
} }
} }
export const greaterThan = (min: number) => (value: number, field = 'Значение') => {
if (value && value <= min) {
return `${field} должно быть больше ${min}`;
}
}
export const lessThan = (max: number) => (value: number, field = 'Значение') => {
if (value && value >= max) {
return `${field} должно быть меньше ${max}`;
}
}
export const equals = (expected: any) => (value: any, field = 'Поле') => { export const equals = (expected: any) => (value: any, field = 'Поле') => {
if (value && value !== expected) { if (value && value !== expected) {
@ -21,13 +10,31 @@ export const equals = (expected: any) => (value: any, field = 'Поле') => {
} }
} }
export const length = (min: number, max: number) => (value: string, field = 'Поле') => { export const number = (value: string, field = 'Поле') => {
if (!/^\d+$/.test(value)) {
return `${field} должно быть числом`;
}
}
export const numGreaterThan = (min: number) => (value: number, field = 'Значение') => {
if (value && value <= min) {
return `${field} должно быть больше ${min}`;
}
}
export const numLessThan = (max: number) => (value: number, field = 'Значение') => {
if (value && value >= max) {
return `${field} должно быть меньше ${max}`;
}
}
export const strLength = (min: number, max: number) => (value: string, field = 'Поле') => {
if (value && (value.length < min || value.length > max)) { if (value && (value.length < min || value.length > max)) {
return `${field} должно содержать от ${min} до ${max} символов`; return `${field} должно содержать от ${min} до ${max} символов`;
} }
} }
export const pattern = (regexp: RegExp, message: string) => (value: string, field = '') => { export const strPattern = (regexp: RegExp, message: string) => (value: string, field = '') => {
if (!regexp.test(value)) { if (!regexp.test(value)) {
return message; return message;
} }
@ -81,12 +88,6 @@ export const nameLength = (value: string, field = 'Поле') => {
} }
} }
export const number = (value: string, field = 'Поле') => {
if (!/^\d+$/.test(value)) {
return `${field} должно быть числом`;
}
}
export const date = (value: string, field = 'Поле') => { export const date = (value: string, field = 'Поле') => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return `${field} должно быть датой в формате ГГГГ-ММ-ДД`; return `${field} должно быть датой в формате ГГГГ-ММ-ДД`;

View File

@ -1,5 +1,6 @@
import axios, {AxiosError, AxiosRequestConfig} from "axios"; import axios, {AxiosError, AxiosRequestConfig} from "axios";
import {NotificationService} from "../services/NotificationService"; import {NotificationService} from "../services/NotificationService";
import {ErrorResponse} from "../models/error";
export const apiUrl = "http://localhost:8080/api/v1/"; export const apiUrl = "http://localhost:8080/api/v1/";
@ -21,7 +22,7 @@ export const request = async <R>(config: AxiosRequestConfig<any>, doReject: bool
resolve(response.data); resolve(response.data);
}).catch((error: AxiosError) => { }).catch((error: AxiosError) => {
if (showError) { if (showError) {
if (error.response) NotificationService.error(JSON.stringify(error.response.data), 'Сервер вернул ошибку, код статуса: ' + error.response.status); if (error.response) NotificationService.error((error.response.data as ErrorResponse).message, 'Сервер вернул ошибку, код статуса: ' + error.response.status);
else if (error.request) NotificationService.error(error.request, 'Не удалось получить ответ от сервера'); else if (error.request) NotificationService.error(error.request, 'Не удалось получить ответ от сервера');
else NotificationService.error(error.message, 'Запрос отправить не удалось'); else NotificationService.error(error.message, 'Запрос отправить не удалось');
} }