ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания

This commit is contained in:
Zuev
2026-05-19 23:10:56 +03:00
parent f010ffc467
commit 91729cff7d
93 changed files with 4264 additions and 699 deletions

View File

@@ -0,0 +1,26 @@
package com.magistr.app.config.auth;
public final class AuthContext {
private static final ThreadLocal<AuthenticatedUser> CURRENT_USER = new ThreadLocal<>();
private AuthContext() {
}
public static void setCurrentUser(AuthenticatedUser user) {
CURRENT_USER.set(user);
}
public static AuthenticatedUser getCurrentUser() {
return CURRENT_USER.get();
}
public static Long currentUserId() {
AuthenticatedUser user = getCurrentUser();
return user == null ? null : user.id();
}
public static void clear() {
CURRENT_USER.remove();
}
}

View File

@@ -0,0 +1,33 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.User;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AuthSessionService {
private final Map<String, AuthenticatedUser> sessions = new ConcurrentHashMap<>();
public String createSession(User user) {
String token = UUID.randomUUID().toString();
sessions.put(token, new AuthenticatedUser(
user.getId(),
user.getUsername(),
user.getRole(),
user.getDepartmentId()
));
return token;
}
public Optional<AuthenticatedUser> findByToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(sessions.get(token));
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) {
}

View File

@@ -0,0 +1,80 @@
package com.magistr.app.config.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.model.Role;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
private final AuthSessionService sessionService;
private final ObjectMapper objectMapper;
public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) {
this.sessionService = sessionService;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (request.getRequestURI().equals("/api/auth/login")) {
return true;
}
String token = bearerToken(request.getHeader("Authorization"));
AuthenticatedUser user = sessionService.findByToken(token).orElse(null);
if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false;
}
AuthContext.setCurrentUser(user);
RequireRoles roles = resolveRoles(handler);
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
}
private RequireRoles resolveRoles(Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return null;
}
RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class);
if (methodRoles != null) {
return methodRoles;
}
return handlerMethod.getBeanType().getAnnotation(RequireRoles.class);
}
private String bearerToken(String header) {
if (header == null || !header.startsWith("Bearer ")) {
return null;
}
return header.substring("Bearer ".length()).trim();
}
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getOutputStream(), Map.of("message", message));
}
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequireRoles {
Role[] value();
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -123,6 +124,9 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
@org.springframework.beans.factory.annotation.Autowired
private TenantRoutingDataSource tenantRoutingDataSource;
@org.springframework.beans.factory.annotation.Autowired
private AuthorizationInterceptor authorizationInterceptor;
@Bean
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
TenantInterceptor interceptor = new TenantInterceptor();
@@ -134,6 +138,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
}
private List<TenantConfig> loadTenantsFromFile() {

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/admin/calendar")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository;

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;

View File

@@ -2,6 +2,8 @@ package com.magistr.app.controller;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.config.auth.AuthSessionService;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
@@ -18,16 +19,23 @@ public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final AuthSessionService sessionService;
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
"EDUCATION_OFFICE", "/admin/#schedule-view",
"DEPARTMENT", "/admin/#department-workspace",
"SCHEDULE_VIEWER", "/admin/#schedule-view",
"TEACHER", "/teacher/",
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
AuthSessionService sessionService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.sessionService = sessionService;
}
@PostMapping("/login")
@@ -42,11 +50,30 @@ public class AuthController {
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
if (user.isArchivedRecord()) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
String token = sessionService.createSession(user);
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
}
@GetMapping("/me")
public ResponseEntity<?> me() {
var user = AuthContext.getCurrentUser();
if (user == null) {
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
}
return ResponseEntity.ok(Map.of(
"userId", user.id(),
"username", user.username(),
"role", user.role().name(),
"departmentId", user.departmentId()
));
}
}

View File

@@ -1,9 +1,12 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomRequest;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Equipment;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -15,6 +18,7 @@ import java.util.Optional;
@RestController
@RequestMapping("/api/classrooms")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ClassroomController {
private final ClassroomRepository classroomRepository;
@@ -26,8 +30,12 @@ public class ClassroomController {
}
@GetMapping
public List<ClassroomResponse> getAllClassrooms() {
return classroomRepository.findAll().stream()
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
List<Classroom> classrooms = includeArchived
? classroomRepository.findAll()
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
return classrooms.stream()
.map(this::mapToResponse)
.toList();
}
@@ -52,7 +60,9 @@ public class ClassroomController {
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -96,6 +106,9 @@ public class ClassroomController {
if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
equipments = equipments.stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -105,15 +118,33 @@ public class ClassroomController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
classroomRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
Classroom classroom = classroomOpt.get();
classroom.archive("Выведена из эксплуатации");
classroom.setIsAvailable(false);
classroomRepository.save(classroom);
return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreClassroom(@PathVariable Long id) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Classroom classroom = classroomOpt.get();
classroom.restore();
classroom.setIsAvailable(true);
classroomRepository.save(classroom);
return ResponseEntity.ok(mapToResponse(classroom));
}
private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
new java.util.ArrayList<>(c.getEquipments()));
}

View File

@@ -1,10 +1,12 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.config.tenant.ConfigMapUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +26,7 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/api/database")
@RequireRoles({Role.ADMIN})
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;

View File

@@ -1,8 +1,11 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,6 +18,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/departments")
@RequireRoles({Role.ADMIN})
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
@@ -26,10 +30,13 @@ public class DepartmentController {
}
@GetMapping
public List<Department> getAllDepartments() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = departmentRepository.findAll();
List<Department> departments = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
@@ -150,12 +157,29 @@ public class DepartmentController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(id)) {
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
departmentRepository.deleteById(id);
logger.info("Кафедра с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
department.archive("Кафедра архивирована");
departmentRepository.save(department);
logger.info("Кафедра с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Кафедра архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreDepartment(@PathVariable Long id) {
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
return ResponseEntity.notFound().build();
}
department.restore();
departmentRepository.save(department);
return ResponseEntity.ok(new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
));
}
}

View File

@@ -0,0 +1,153 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/department")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController {
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping("/subjects")
public List<Subject> getSubjects(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping("/subjects/import")
public ResponseEntity<?> importSubjects(@RequestBody List<CreateSubjectRequest> requests) {
if (requests == null || requests.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки"));
}
Long requestedDepartmentId = requests.stream()
.map(CreateSubjectRequest::getDepartmentId)
.filter(id -> id != null && id > 0)
.findFirst()
.orElse(null);
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
List<Subject> saved = requests.stream()
.filter(request -> request.getName() != null && !request.getName().isBlank())
.map(request -> {
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
subject.setName(request.getName().trim());
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
return subjectRepository.save(subject);
})
.toList();
return ResponseEntity.ok(Map.of(
"message", "Дисциплины загружены",
"count", saved.size()
));
}
@GetMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> getComments(@PathVariable Long subjectId) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream()
.map(this::toCommentDto)
.toList());
}
@PostMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> addComment(@PathVariable Long subjectId, @RequestBody Map<String, String> request) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
String commentText = request == null ? null : request.get("comment");
if (commentText == null || commentText.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
}
SubjectComment comment = new SubjectComment();
comment.setSubject(subject);
comment.setComment(commentText.trim());
Long currentUserId = AuthContext.currentUserId();
if (currentUserId != null) {
userRepository.findById(currentUserId).ifPresent(comment::setAuthor);
}
return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment)));
}
@GetMapping("/teachers")
public List<?> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
}
@GetMapping("/schedule")
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId,
null, null, null, null, startDate, endDate));
}
private SubjectCommentDto toCommentDto(SubjectComment comment) {
User author = comment.getAuthor();
return new SubjectCommentDto(
comment.getId(),
comment.getSubject().getId(),
author == null ? null : author.getId(),
author == null ? null : author.getFullName(),
comment.getComment(),
comment.getCreatedAt()
);
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId();
}
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
}
private boolean canAccessDepartment(Long departmentId) {
var user = AuthContext.getCurrentUser();
return user == null
|| user.role() == Role.ADMIN
|| (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId()));
}
}

View File

@@ -1,6 +1,8 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository;
@@ -13,6 +15,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;
@@ -28,6 +31,7 @@ public class EducationFormController {
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Map<String, Object>> getAll() {
return educationFormRepository.findAllByOrderByNameAsc().stream()
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))

View File

@@ -1,5 +1,8 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Equipment;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -10,6 +13,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/equipments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EquipmentController {
private final EquipmentRepository equipmentRepository;
@@ -19,8 +23,8 @@ public class EquipmentController {
}
@GetMapping
public List<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping
@@ -42,10 +46,23 @@ public class EquipmentController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
if (!equipmentRepository.existsById(id)) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
equipment.archive("Оборудование архивировано");
equipmentRepository.save(equipment);
return ResponseEntity.ok(Map.of("message", "Оборудование архивировано"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreEquipment(@PathVariable Long id) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipment.restore();
equipmentRepository.save(equipment);
return ResponseEntity.ok(equipment);
}
}

View File

@@ -1,11 +1,14 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
@@ -25,6 +28,7 @@ import java.util.Optional;
@RestController
@RequestMapping("/api/groups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
@@ -57,11 +61,13 @@ public class GroupController {
}
@GetMapping
public List<GroupResponse> getAllGroups() {
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = groupRepository.findAll();
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
@@ -78,7 +84,7 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -101,6 +107,7 @@ public class GroupController {
}
@PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
@@ -146,6 +153,7 @@ public class GroupController {
}
@PutMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
try {
@@ -195,15 +203,30 @@ public class GroupController {
}
@DeleteMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id);
if (!groupRepository.existsById(id)) {
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
logger.info("Группа с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
group.archive("Группа архивирована");
groupRepository.save(group);
logger.info("Группа с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
}
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreGroup(@PathVariable Long id) {
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
group.restore();
groupRepository.save(group);
return ResponseEntity.ok(mapToResponse(group));
}
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
@@ -260,6 +283,7 @@ public class GroupController {
}
@GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
@@ -270,6 +294,7 @@ public class GroupController {
}
@PutMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
@@ -309,6 +334,7 @@ public class GroupController {
}
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {

View File

@@ -1,7 +1,9 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.LessonTypeResponse;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.Role;
import com.magistr.app.repository.LessonTypesRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -12,6 +14,7 @@ import java.util.List;
@RestController
@RequestMapping("/api/lesson-types")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class LessonTypeController {
private final LessonTypesRepository lessonTypesRepository;

View File

@@ -1,7 +1,9 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
@@ -14,14 +16,15 @@ import java.util.Map;
@RestController
@RequestMapping("/api/schedule")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleQueryService scheduleQueryService;
public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
@@ -40,9 +43,18 @@ public class ScheduleController {
}
try {
List<RenderedLessonDto> lessons = groupId != null
? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate)
: scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
List<RenderedLessonDto> lessons = scheduleQueryService.search(
groupId,
teacherId,
null,
null,
null,
null,
null,
null,
startDate,
endDate
);
return ResponseEntity.ok(lessons);
} catch (IllegalArgumentException e) {
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());

View File

@@ -0,0 +1,148 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/edu-office/schedule/overrides")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleOverrideController {
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
return save(null, request);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return save(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
scheduleOverrideRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
}
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
ScheduleOverride override = id == null
? scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
.orElseGet(ScheduleOverride::new)
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
override.setLessonDate(request.lessonDate());
override.setAction(request.action());
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
override.setNewLessonFormat(clean(request.newLessonFormat()));
override.setComment(clean(request.comment()));
override.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.save(override);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
private String validate(ScheduleOverrideDto request) {
if (request == null) {
return "Передайте данные изменения расписания";
}
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
return "Базовый слот расписания не найден";
}
if (request.lessonDate() == null) {
return "Дата пары обязательна";
}
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
return "Недопустимое действие изменения расписания";
}
if (request.newClassroomId() != null) {
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
return "Активная аудитория не найдена";
}
}
if (request.newTeacherId() != null) {
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return "Активный преподаватель не найден";
}
}
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
return "Временной слот не найден";
}
return null;
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*;
@@ -14,6 +15,7 @@ import java.util.*;
@RestController
@RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
@@ -112,12 +114,14 @@ public class ScheduleRuleAdminController {
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleRuleRepository.existsById(id)) {
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
scheduleRuleRepository.deleteById(id);
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Правило расписания удалено"));
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private String validateRule(ScheduleRuleDto request) {
@@ -143,12 +147,18 @@ public class ScheduleRuleAdminController {
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
rule.setSubject(subject);
rule.setSemester(semester);
@@ -186,8 +196,14 @@ public class ScheduleRuleAdminController {
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);

View File

@@ -0,0 +1,63 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Map;
@RestController
@RequestMapping("/api/schedule/search")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleSearchController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class);
private final ScheduleQueryService scheduleQueryService;
public ScheduleSearchController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
public ResponseEntity<?> search(
@RequestParam(required = false) Long groupId,
@RequestParam(required = false) Long teacherId,
@RequestParam(required = false) Long classroomId,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long subjectId,
@RequestParam(required = false) Long lessonTypeId,
@RequestParam(required = false) Long timeSlotId,
@RequestParam(required = false) String parity,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
groupId, teacherId, classroomId, departmentId, startDate, endDate);
try {
return ResponseEntity.ok(scheduleQueryService.search(
groupId,
teacherId,
classroomId,
departmentId,
subjectId,
lessonTypeId,
timeSlotId,
parity,
startDate,
endDate
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
}
}
}

View File

@@ -1,8 +1,11 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.dto.SpecialtyProfileDto;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.SpecialtiesRepository;
@@ -18,6 +21,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/specialties")
@RequireRoles({Role.ADMIN})
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
@@ -32,10 +36,12 @@ public class SpecialityController {
}
@GetMapping
public List<Speciality> getAllSpecialties() {
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = specialtiesRepository.findAll();
List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
@@ -161,13 +167,30 @@ public class SpecialityController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(id)) {
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
specialtiesRepository.deleteById(id);
logger.info("Специальность с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Специальность удалена"));
speciality.archive("Специальность архивирована");
specialtiesRepository.save(speciality);
logger.info("Специальность с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Специальность архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreSpeciality(@PathVariable Long id) {
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
return ResponseEntity.notFound().build();
}
speciality.restore();
specialtiesRepository.save(speciality);
return ResponseEntity.ok(new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
));
}
@GetMapping("/{specialtyId}/profiles")

View File

@@ -1,6 +1,8 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.SubgroupDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.repository.GroupRepository;
@@ -15,6 +17,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
private final SubgroupRepository subgroupRepository;
@@ -50,6 +53,7 @@ public class SubgroupController {
}
@PostMapping("/api/groups/{groupId}/subgroups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findById(groupId).orElse(null);
if (group == null) {
@@ -73,6 +77,7 @@ public class SubgroupController {
}
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
@@ -99,6 +104,7 @@ public class SubgroupController {
}
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
@@ -108,9 +114,10 @@ public class SubgroupController {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
try {
subgroupRepository.delete(subgroup);
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена"));
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}

View File

@@ -1,7 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
@@ -16,6 +19,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
@RequireRoles({Role.ADMIN})
public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
@@ -27,10 +31,13 @@ public class SubjectController {
}
@GetMapping
public List<Subject> getAllSubjects() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = subjectRepository.findAll();
List<Subject> subjects = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> response = subjects.stream()
.map(s -> new Subject(
s.getId(),
@@ -48,10 +55,11 @@ public class SubjectController {
}
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
@@ -121,12 +129,31 @@ public class SubjectController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
if (!subjectRepository.existsById(id)) {
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
logger.info("Дисциплина с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
subject.archive("Дисциплина архивирована");
subjectRepository.save(subject);
logger.info("Дисциплина с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована"));
}
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreSubject(@PathVariable Long id) {
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
return ResponseEntity.notFound().build();
}
subject.restore();
subjectRepository.save(subject);
return ResponseEntity.ok(new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
));
}
}

View File

@@ -1,8 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherSubjectResponse;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
@@ -14,6 +19,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
if (!userRepository.existsById(userId)) {
User user = userRepository.findById(userId).orElse(null);
if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (!subjectRepository.existsById(subjectId)) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || subject.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
}
if (!canManage(user, subject)) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) {
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) {
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
if (teacherSubject == null) {
return ResponseEntity.notFound().build();
}
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
}
teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
}
private boolean canManage(User teacher, Subject subject) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null || currentUser.role() == Role.ADMIN) {
return true;
}
if (currentUser.role() != Role.DEPARTMENT) {
return false;
}
Long departmentId = currentUser.departmentId();
return departmentId != null
&& teacher != null
&& subject != null
&& departmentId.equals(teacher.getDepartmentId())
&& departmentId.equals(subject.getDepartmentId());
}
}

View File

@@ -1,11 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.dto.TimeSlotScopeDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
@@ -23,6 +25,7 @@ import java.util.UUID;
@RestController
@RequestMapping("/api/admin/time-slots")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class TimeSlotAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";

View File

@@ -1,43 +1,61 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequireRoles({Role.ADMIN})
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
}
@GetMapping
public List<UserResponse> getAllUsers() {
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
try {
List<User> users = userRepository.findAll();
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream()
.map(u -> {
@@ -45,13 +63,15 @@ public class UserController {
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
UserResponse userResponse = new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
})
.toList();
logger.info("Получено {} пользователей", response.size());
@@ -64,11 +84,12 @@ public class UserController {
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try {
List<User> users = userRepository.findByRole(Role.TEACHER);
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream()
.map(u -> {
@@ -76,13 +97,15 @@ public class UserController {
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
UserResponse userResponse = new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
})
.toList();
logger.info("Получено {} преподавателей", response.size());
@@ -94,10 +117,15 @@ public class UserController {
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
try {
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -178,6 +206,19 @@ public class UserController {
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
if (department != null) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
}
logger.info("Пользователь успешно создан с ID: {}", user.getId());
@@ -192,12 +233,111 @@ public class UserController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
if (!userRepository.existsById(id)) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
userRepository.deleteById(id);
logger.info("Пользователь с ID - {} успешно удалён", id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
user.archive("Пользователь архивирован");
userRepository.save(user);
logger.info("Пользователь с ID - {} успешно архивирован", id);
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
user.restore();
userRepository.save(user);
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
}
@GetMapping("/{id}/department-history")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
.map(this::toAssignmentDto)
.toList());
}
@PostMapping("/{id}/department-transfer")
@Transactional
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
User teacher = userRepository.findById(id).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
}
if (request.departmentId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
.ifPresent(current -> {
if (current.getValidFrom().isBefore(validFrom)) {
current.setValidTo(validFrom.minusDays(1));
teacherDepartmentAssignmentRepository.save(current);
} else {
teacherDepartmentAssignmentRepository.delete(current);
}
});
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setPrimaryAssignment(true);
assignment.setComment(request.comment());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
return ResponseEntity.ok(toAssignmentDto(assignment));
}
@GetMapping("/teachers/by-department/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
.map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
.toList());
}
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
return new TeacherDepartmentAssignmentDto(
assignment.getId(),
assignment.getTeacher().getId(),
assignment.getTeacher().getFullName(),
assignment.getDepartment().getId(),
assignment.getDepartment().getDepartmentName(),
assignment.getValidFrom(),
assignment.getValidTo(),
assignment.getPrimaryAssignment(),
assignment.getComment()
);
}
}

View File

@@ -0,0 +1,188 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/workload")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class WorkloadController {
private static final int ACADEMIC_HOURS_PER_LESSON = 2;
private final ScheduleQueryService scheduleQueryService;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
public WorkloadController(ScheduleQueryService scheduleQueryService,
ClassroomRepository classroomRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
this.scheduleQueryService = scheduleQueryService;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
}
@GetMapping("/teachers")
public List<WorkloadSummaryDto> teacherWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
teacherId -> {
User teacher = teachersById.get(teacherId);
return teacher == null ? null : teacher.getDepartmentId();
});
}
@GetMapping("/classrooms")
public List<WorkloadSummaryDto> classroomWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
}
@GetMapping("/departments")
public List<WorkloadSummaryDto> departmentWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
.collect(Collectors.groupingBy(lesson -> {
User teacher = teachersById.get(lesson.teacherId());
return teacher == null ? 0L : teacher.getDepartmentId();
}));
return byDepartment.entrySet().stream()
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
@GetMapping("/time-slots")
public List<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
return summarize(lessons, RenderedLessonDto::timeSlotId,
lesson -> lesson.timeSlotOrder() == null
? "Пара"
: lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")",
ignored -> null);
}
@GetMapping("/free-classrooms")
public List<ClassroomResponse> freeClassrooms(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
null, null, timeSlotId, null, date, date)
.stream()
.map(RenderedLessonDto::classroomId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
.filter(Classroom::getIsAvailable)
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
.map(this::toClassroomResponse)
.toList();
}
private List<WorkloadSummaryDto> summarize(List<RenderedLessonDto> lessons,
Function<RenderedLessonDto, Long> idFn,
Function<RenderedLessonDto, String> nameFn,
Function<Long, Long> departmentFn) {
Map<Long, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> idFn.apply(lesson) != null)
.collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream()
.map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List<RenderedLessonDto> lessons) {
return new WorkloadSummaryDto(
id,
name,
departmentId,
departmentName(departmentId),
lessons.size(),
lessons.size() * ACADEMIC_HOURS_PER_LESSON,
lessons.stream()
.map(lesson -> lesson.date() + ":" + lesson.timeSlotId())
.distinct()
.count()
);
}
private String departmentName(Long departmentId) {
if (departmentId == null) {
return null;
}
return departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Кафедра не найдена");
}
private ClassroomResponse toClassroomResponse(Classroom classroom) {
return new ClassroomResponse(
classroom.getId(),
classroom.getName(),
classroom.getCapacity(),
classroom.getBuilding(),
classroom.getFloor(),
classroom.getIsAvailable(),
classroom.getStatus(),
classroom.getArchivedAt(),
classroom.getArchiveReason(),
new ArrayList<>(classroom.getEquipments())
);
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.util.List;
public class ClassroomResponse {
@@ -10,6 +11,9 @@ public class ClassroomResponse {
private String building;
private Integer floor;
private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private String archiveReason;
private List<Equipment> equipments;
public ClassroomResponse() {
@@ -17,12 +21,21 @@ public class ClassroomResponse {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> equipments) {
this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments);
}
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, String status, LocalDateTime archivedAt,
String archiveReason, List<Equipment> equipments) {
this.id = id;
this.name = name;
this.capacity = capacity;
this.building = building;
this.floor = floor;
this.isAvailable = isAvailable;
this.status = status;
this.archivedAt = archivedAt;
this.archiveReason = archiveReason;
this.equipments = equipments;
}
@@ -74,6 +87,30 @@ public class ClassroomResponse {
this.isAvailable = isAvailable;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
this.archivedAt = archivedAt;
}
public String getArchiveReason() {
return archiveReason;
}
public void setArchiveReason(String archiveReason) {
this.archiveReason = archiveReason;
}
public List<Equipment> getEquipments() {
return equipments;
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record DepartmentTransferRequest(
Long departmentId,
LocalDate validFrom,
String comment
) {
}

View File

@@ -0,0 +1,19 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.time.LocalDateTime;
public record ScheduleOverrideDto(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment,
Long createdBy,
LocalDateTime createdAt
) {
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
public record SubjectCommentDto(
Long id,
Long subjectId,
Long authorId,
String authorName,
String comment,
LocalDateTime createdAt
) {
}

View File

@@ -0,0 +1,16 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherDepartmentAssignmentDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
String departmentName,
LocalDate validFrom,
LocalDate validTo,
Boolean primaryAssignment,
String comment
) {
}

View File

@@ -12,6 +12,7 @@ public class UserResponse {
private String jobTitle;
private String departmentName;
private Long departmentId;
private String status;
public UserResponse() {
}
@@ -69,4 +70,12 @@ public class UserResponse {
public Long getDepartmentId() {
return departmentId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record WorkloadSummaryDto(
Long id,
String name,
Long departmentId,
String departmentName,
long lessonCount,
int academicHours,
long occupiedSlotCount
) {
}

View File

@@ -6,7 +6,7 @@ import java.util.Set;
@Entity
@Table(name = "classrooms")
public class Classroom {
public class Classroom extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="departments")
public class Department {
public class Department extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "education_forms")
public class EducationForm {
public class EducationForm extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "equipments")
public class Equipment {
public class Equipment extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -0,0 +1,104 @@
package com.magistr.app.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDate;
import java.time.LocalDateTime;
@MappedSuperclass
public abstract class LifecycleEntity {
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_ARCHIVED = "ARCHIVED";
@Column(name = "status", nullable = false, length = 20)
private String status = STATUS_ACTIVE;
@Column(name = "active_from")
private LocalDate activeFrom;
@Column(name = "active_to")
private LocalDate activeTo;
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@Column(name = "archive_reason")
private String archiveReason;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status == null || status.isBlank() ? STATUS_ACTIVE : status;
}
public LocalDate getActiveFrom() {
return activeFrom;
}
public void setActiveFrom(LocalDate activeFrom) {
this.activeFrom = activeFrom;
}
public LocalDate getActiveTo() {
return activeTo;
}
public void setActiveTo(LocalDate activeTo) {
this.activeTo = activeTo;
}
public LocalDateTime getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
this.archivedAt = archivedAt;
}
public String getArchiveReason() {
return archiveReason;
}
public void setArchiveReason(String archiveReason) {
this.archiveReason = archiveReason;
}
@JsonIgnore
public boolean isActiveRecord() {
return !STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isArchivedRecord() {
return STATUS_ARCHIVED.equals(status);
}
@JsonIgnore
public boolean isActiveOn(LocalDate date) {
if (date == null) {
return isActiveRecord();
}
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
return afterStart && beforeEnd;
}
public void archive(String reason) {
status = STATUS_ARCHIVED;
archivedAt = LocalDateTime.now();
activeTo = LocalDate.now();
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
}
public void restore() {
status = STATUS_ACTIVE;
archivedAt = null;
archiveReason = null;
activeTo = null;
}
}

View File

@@ -2,6 +2,9 @@ package com.magistr.app.model;
public enum Role {
ADMIN,
EDUCATION_OFFICE,
DEPARTMENT,
SCHEDULE_VIEWER,
TEACHER,
STUDENT
}

View File

@@ -0,0 +1,133 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "schedule_overrides")
public class ScheduleOverride {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "base_rule_slot_id", nullable = false)
private ScheduleRuleSlot baseRuleSlot;
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(nullable = false, length = 20)
private String action;
@ManyToOne
@JoinColumn(name = "new_time_slot_id")
private TimeSlot newTimeSlot;
@ManyToOne
@JoinColumn(name = "new_classroom_id")
private Classroom newClassroom;
@ManyToOne
@JoinColumn(name = "new_teacher_id")
private User newTeacher;
@Column(name = "new_lesson_format", length = 30)
private String newLessonFormat;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
public Long getId() {
return id;
}
public ScheduleRuleSlot getBaseRuleSlot() {
return baseRuleSlot;
}
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) {
this.baseRuleSlot = baseRuleSlot;
}
public LocalDate getLessonDate() {
return lessonDate;
}
public void setLessonDate(LocalDate lessonDate) {
this.lessonDate = lessonDate;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public TimeSlot getNewTimeSlot() {
return newTimeSlot;
}
public void setNewTimeSlot(TimeSlot newTimeSlot) {
this.newTimeSlot = newTimeSlot;
}
public Classroom getNewClassroom() {
return newClassroom;
}
public void setNewClassroom(Classroom newClassroom) {
this.newClassroom = newClassroom;
}
public User getNewTeacher() {
return newTeacher;
}
public void setNewTeacher(User newTeacher) {
this.newTeacher = newTeacher;
}
public String getNewLessonFormat() {
return newLessonFormat;
}
public void setNewLessonFormat(String newLessonFormat) {
this.newLessonFormat = newLessonFormat;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
@@ -21,6 +22,15 @@ public class ScheduleRule {
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(nullable = false, length = 20)
private String status = LifecycleEntity.STATUS_ACTIVE;
@Column(name = "valid_from")
private LocalDate validFrom;
@Column(name = "valid_to")
private LocalDate validTo;
@Column(name = "lecture_academic_hours", nullable = false)
private Integer lectureAcademicHours = 0;
@@ -74,6 +84,30 @@ public class ScheduleRule {
this.semester = semester;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDate getValidFrom() {
return validFrom;
}
public void setValidFrom(LocalDate validFrom) {
this.validFrom = validFrom;
}
public LocalDate getValidTo() {
return validTo;
}
public void setValidTo(LocalDate validTo) {
this.validTo = validTo;
}
public Integer getLectureAcademicHours() {
return lectureAcademicHours;
}

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="specialties")
public class Speciality {
public class Speciality extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "specialty_profiles")
public class SpecialtyProfile {
public class SpecialtyProfile extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "student_groups")
public class StudentGroup {
public class StudentGroup extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "subgroups")
public class Subgroup {
public class Subgroup extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "subjects")
public class Subject {
public class Subject extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -0,0 +1,64 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "subject_comments")
public class SubjectComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "subject_id", nullable = false)
private Subject subject;
@ManyToOne
@JoinColumn(name = "author_id")
private User author;
@Column(nullable = false, columnDefinition = "TEXT")
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
public Long getId() {
return id;
}
public Subject getSubject() {
return subject;
}
public void setSubject(Subject subject) {
this.subject = subject;
}
public User getAuthor() {
return author;
}
public void setAuthor(User author) {
this.author = author;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,109 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "teacher_department_assignments")
public class TeacherDepartmentAssignment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
@Column(name = "valid_from", nullable = false)
private LocalDate validFrom;
@Column(name = "valid_to")
private LocalDate validTo;
@Column(name = "is_primary", nullable = false)
private Boolean primaryAssignment = true;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "created_by")
private Long createdBy;
public Long getId() {
return id;
}
public User getTeacher() {
return teacher;
}
public void setTeacher(User teacher) {
this.teacher = teacher;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
public LocalDate getValidFrom() {
return validFrom;
}
public void setValidFrom(LocalDate validFrom) {
this.validFrom = validFrom;
}
public LocalDate getValidTo() {
return validTo;
}
public void setValidTo(LocalDate validTo) {
this.validTo = validTo;
}
public Boolean getPrimaryAssignment() {
return primaryAssignment;
}
public void setPrimaryAssignment(Boolean primaryAssignment) {
this.primaryAssignment = primaryAssignment;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
}

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
public class User extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,10 @@ import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
Optional<Classroom> findByName(String name);
List<Classroom> findByStatusNot(String status);
}

View File

@@ -4,10 +4,13 @@ import com.magistr.app.model.Department;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface DepartmentRepository extends JpaRepository<Department, Long> {
Optional<Department> findByDepartmentName(String departmentName);
Optional<Department> findByDepartmentCode(Long departmentCode);
List<Department> findByStatusNot(String status);
}

View File

@@ -4,7 +4,10 @@ import com.magistr.app.model.Equipment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface EquipmentRepository extends JpaRepository<Equipment, Long> {
Optional<Equipment> findByName(String name);
List<Equipment> findByStatusNot(String status);
}

View File

@@ -10,4 +10,8 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
List<StudentGroup> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId);
List<StudentGroup> findByStatusNot(String status);
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
}

View File

@@ -0,0 +1,29 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleOverride;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverride, Long> {
@Query("""
select o
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
""")
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
}

View File

@@ -26,6 +26,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where g.id = :groupId
and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdAndSemesterId(
@Param("groupId") Long groupId,
@@ -49,6 +50,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterId(
@Param("teacherId") Long teacherId,
@@ -69,6 +71,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where r.status <> 'ARCHIVED'
order by r.id desc
""")
List<ScheduleRule> findAllWithDetails();

View File

@@ -4,10 +4,13 @@ import com.magistr.app.model.Speciality;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
Optional<Speciality> findBySpecialityName(String specialityName);
Optional<Speciality> findBySpecialityCode(String specialityCode);
List<Speciality> findByStatusNot(String status);
}

View File

@@ -14,6 +14,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup
where subgroup.status <> 'ARCHIVED'
order by studentGroup.name, subgroup.name
""")
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
@@ -23,6 +24,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
from Subgroup subgroup
join fetch subgroup.studentGroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
order by subgroup.name
""")
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@@ -41,6 +43,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and lower(subgroup.name) = lower(:name)
and subgroup.status <> 'ARCHIVED'
""")
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.SubjectComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SubjectCommentRepository extends JpaRepository<SubjectComment, Long> {
List<SubjectComment> findBySubjectIdOrderByCreatedAtDesc(Long subjectId);
}

View File

@@ -10,4 +10,8 @@ public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
List<Subject> findByDepartmentId(Long departmentId);
List<Subject> findByStatusNot(String status);
List<Subject> findByDepartmentIdAndStatusNot(Long departmentId, String status);
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherDepartmentAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.teacher
join fetch a.department
where a.teacher.id = :teacherId
order by a.validFrom desc, a.id desc
""")
List<TeacherDepartmentAssignment> findHistoryByTeacherId(@Param("teacherId") Long teacherId);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.teacher
join fetch a.department
where a.teacher.id = :teacherId
and a.primaryAssignment = true
and a.validTo is null
""")
Optional<TeacherDepartmentAssignment> findOpenPrimaryByTeacherId(@Param("teacherId") Long teacherId);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.teacher
join fetch a.department
where a.department.id = :departmentId
and a.primaryAssignment = true
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
order by a.teacher.fullName
""")
List<TeacherDepartmentAssignment> findDepartmentTeachersAtDate(
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
}

View File

@@ -14,4 +14,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRole(Role role);
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
List<User> findByStatusNot(String status);
List<User> findByRoleAndStatusNot(Role role, String status);
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
}

View File

@@ -167,6 +167,9 @@ public class ScheduleGeneratorService {
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
return;
}
if (!rule.getSubject().isActiveOn(date)) {
return;
}
Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,6 +196,12 @@ public class ScheduleGeneratorService {
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
for (ScheduleRuleSlot slot : slots) {
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
continue;
}
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
continue;
}
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
if (weekNumber < rule.startWeekFor(category)) {
continue;
@@ -254,10 +263,13 @@ public class ScheduleGeneratorService {
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
if (targetGroup != null) {
return academicDateService.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of();
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
? List.of(targetGroup)
: List.of();
}
return rule.getGroups().stream()
.filter(group -> group.isActiveOn(date))
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();

View File

@@ -0,0 +1,246 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class ScheduleQueryService {
private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository;
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository) {
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
}
public List<RenderedLessonDto> search(Long groupId,
Long teacherId,
Long classroomId,
Long departmentId,
Long subjectId,
Long lessonTypeId,
Long timeSlotId,
String parity,
LocalDate startDate,
LocalDate endDate) {
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
List<RenderedLessonDto> generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
.filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
.filter(lesson -> parity == null || parity.isBlank() || lesson.parity().name().equalsIgnoreCase(parity))
.toList();
return deduplicate(lessons);
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons, LocalDate startDate, LocalDate endDate) {
Map<String, ScheduleOverride> overrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate)
.stream()
.collect(Collectors.toMap(
override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()),
override -> override,
(first, second) -> second
));
if (overrides.isEmpty()) {
return lessons;
}
return lessons.stream()
.map(lesson -> {
ScheduleOverride override = overrides.get(overrideKey(lesson.scheduleRuleSlotId(), lesson.date()));
return override == null ? lesson : applyOverride(lesson, override);
})
.filter(Objects::nonNull)
.toList();
}
private RenderedLessonDto applyOverride(RenderedLessonDto lesson, ScheduleOverride override) {
if ("CANCEL".equals(override.getAction())) {
return null;
}
Long timeSlotId = lesson.timeSlotId();
Integer timeSlotOrder = lesson.timeSlotOrder();
java.time.LocalTime startTime = lesson.startTime();
java.time.LocalTime endTime = lesson.endTime();
if (override.getNewTimeSlot() != null) {
timeSlotId = override.getNewTimeSlot().getId();
timeSlotOrder = override.getNewTimeSlot().getOrderNumber();
startTime = override.getNewTimeSlot().getStartTime();
endTime = override.getNewTimeSlot().getEndTime();
}
Long classroomId = override.getNewClassroom() == null ? lesson.classroomId() : override.getNewClassroom().getId();
String classroomName = override.getNewClassroom() == null ? lesson.classroomName() : override.getNewClassroom().getName();
Long teacherId = override.getNewTeacher() == null ? lesson.teacherId() : override.getNewTeacher().getId();
String teacherName = override.getNewTeacher() == null ? lesson.teacherName() : ScheduleGeneratorService.displayUserName(override.getNewTeacher());
String lessonFormat = override.getNewLessonFormat() == null ? lesson.lessonFormat() : override.getNewLessonFormat();
return new RenderedLessonDto(
lesson.scheduleRuleId(),
lesson.scheduleRuleSlotId(),
lesson.date(),
lesson.dayOfWeek(),
lesson.dayName(),
lesson.weekNumber(),
lesson.parity(),
timeSlotId,
timeSlotOrder,
startTime,
endTime,
lesson.subjectId(),
lesson.subjectName(),
teacherId,
teacherName,
classroomId,
classroomName,
lesson.lessonTypeId(),
lesson.lessonTypeName(),
lessonFormat,
lesson.subgroupId(),
lesson.subgroupName(),
lesson.subgroupIds(),
lesson.subgroupNames(),
lesson.groupIds(),
lesson.groupNames(),
lesson.activityType(),
lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
lesson.remainingLessonTypeAcademicHoursAfterLesson()
);
}
private String overrideKey(Long scheduleRuleSlotId, LocalDate date) {
return scheduleRuleSlotId + ":" + date;
}
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId) {
if (groupId != null) {
return groupRepository.findById(groupId)
.filter(StudentGroup::isActiveRecord)
.map(List::of)
.orElse(List.of());
}
if (departmentId != null) {
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
}
return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
}
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
Map<String, MutableLesson> unique = new LinkedHashMap<>();
for (RenderedLessonDto lesson : lessons) {
String key = lessonKey(lesson);
unique.computeIfAbsent(key, ignored -> new MutableLesson(lesson)).merge(lesson);
}
return unique.values().stream()
.map(MutableLesson::toDto)
.sorted(Comparator
.comparing(RenderedLessonDto::date)
.thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo)))
.toList();
}
private String lessonKey(RenderedLessonDto lesson) {
return String.join(":",
String.valueOf(lesson.date()),
String.valueOf(lesson.scheduleRuleSlotId()),
String.valueOf(lesson.classroomId()),
String.valueOf(lesson.timeSlotOrder()),
String.valueOf(lesson.teacherId()),
String.valueOf(lesson.subjectId()),
sortedSignature(lesson.subgroupIds())
);
}
private String sortedSignature(List<Long> values) {
if (values == null || values.isEmpty()) {
return "";
}
return values.stream()
.map(String::valueOf)
.sorted()
.collect(Collectors.joining(","));
}
private static class MutableLesson {
private final RenderedLessonDto source;
private final LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
private final LinkedHashSet<String> groupNames = new LinkedHashSet<>();
private final LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
private final LinkedHashSet<String> subgroupNames = new LinkedHashSet<>();
MutableLesson(RenderedLessonDto source) {
this.source = source;
}
void merge(RenderedLessonDto lesson) {
if (lesson.groupIds() != null) {
groupIds.addAll(lesson.groupIds());
}
if (lesson.groupNames() != null) {
groupNames.addAll(lesson.groupNames());
}
if (lesson.subgroupIds() != null) {
subgroupIds.addAll(lesson.subgroupIds());
}
if (lesson.subgroupNames() != null) {
subgroupNames.addAll(lesson.subgroupNames());
}
}
RenderedLessonDto toDto() {
return new RenderedLessonDto(
source.scheduleRuleId(),
source.scheduleRuleSlotId(),
source.date(),
source.dayOfWeek(),
source.dayName(),
source.weekNumber(),
source.parity(),
source.timeSlotId(),
source.timeSlotOrder(),
source.startTime(),
source.endTime(),
source.subjectId(),
source.subjectName(),
source.teacherId(),
source.teacherName(),
source.classroomId(),
source.classroomName(),
source.lessonTypeId(),
source.lessonTypeName(),
source.lessonFormat(),
subgroupIds.size() == 1 ? subgroupIds.iterator().next() : null,
subgroupNames.size() == 1 ? subgroupNames.iterator().next() : null,
List.copyOf(subgroupIds),
List.copyOf(subgroupNames),
List.copyOf(groupIds),
List.copyOf(groupNames),
source.activityType(),
source.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(),
source.remainingLessonTypeAcademicHoursAfterLesson()
);
}
}
}

View File

@@ -9,7 +9,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS departments (
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
code BIGINT UNIQUE NOT NULL
code BIGINT UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO departments (name, code) VALUES
@@ -20,7 +26,13 @@ INSERT INTO departments (name, code) VALUES
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
specialty_code VARCHAR(255) UNIQUE NOT NULL
specialty_code VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO specialties (name, specialty_code) VALUES
@@ -35,6 +47,12 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
name VARCHAR(500) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT,
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
);
@@ -50,20 +68,62 @@ CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
role VARCHAR(30) NOT NULL DEFAULT 'STUDENT',
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
department_id BIGINT NOT NULL REFERENCES departments(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2)
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
('кафедраб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS teacher_department_assignments (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
department_id BIGINT NOT NULL REFERENCES departments(id),
valid_from DATE NOT NULL,
valid_to DATE,
is_primary BOOLEAN NOT NULL DEFAULT TRUE,
comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT REFERENCES users(id),
CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
);
CREATE INDEX IF NOT EXISTS idx_teacher_department_teacher_dates
ON teacher_department_assignments(teacher_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS idx_teacher_department_department_dates
ON teacher_department_assignments(department_id, valid_from, valid_to);
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
WHERE u.role = 'TEACHER'
AND NOT EXISTS (
SELECT 1
FROM teacher_department_assignments existing
WHERE existing.teacher_id = u.id
);
-- ==========================================
-- Образовательные формы
-- ==========================================
@@ -71,7 +131,13 @@ CREATE TABLE IF NOT EXISTS education_forms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO education_forms (name) VALUES
@@ -92,7 +158,13 @@ CREATE TABLE IF NOT EXISTS student_groups (
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
year_start_study BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
-- Тестовая базовая группа для работы
@@ -116,6 +188,12 @@ CREATE TABLE IF NOT EXISTS subgroups (
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
student_capacity INT,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT,
UNIQUE(group_id, name)
);
@@ -146,7 +224,13 @@ CREATE TABLE IF NOT EXISTS subjects (
code VARCHAR(20),
department_id BIGINT NOT NULL REFERENCES departments(id),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO subjects (name, department_id) VALUES
@@ -157,6 +241,17 @@ INSERT INTO subjects (name, department_id) VALUES
('Английский язык', 1)
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
author_id BIGINT REFERENCES users(id),
comment TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subject_comments_subject
ON subject_comments(subject_id, created_at DESC);
-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
@@ -176,7 +271,13 @@ CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
inventory_number VARCHAR(50)
inventory_number VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO equipments (name) VALUES
@@ -197,7 +298,13 @@ CREATE TABLE IF NOT EXISTS classrooms (
floor INT,
is_available BOOLEAN DEFAULT TRUE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMP,
archived_by BIGINT,
archive_reason TEXT
);
INSERT INTO classrooms (name, capacity, building, floor) VALUES
@@ -229,6 +336,17 @@ WHERE
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
CREATE INDEX IF NOT EXISTS idx_education_forms_status ON education_forms(status);
CREATE INDEX IF NOT EXISTS idx_student_groups_status ON student_groups(status);
CREATE INDEX IF NOT EXISTS idx_subgroups_status ON subgroups(status);
CREATE INDEX IF NOT EXISTS idx_subjects_status ON subjects(status);
CREATE INDEX IF NOT EXISTS idx_equipments_status ON equipments(status);
CREATE INDEX IF NOT EXISTS idx_classrooms_status ON classrooms(status);
-- ==========================================
-- Связи для преподавателей
-- ==========================================
@@ -483,6 +601,13 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
lecture_start_week INT NOT NULL,
laboratory_start_week INT NOT NULL,
practice_start_week INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
valid_from DATE,
valid_to DATE,
version_group_id BIGINT,
change_reason TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
lecture_academic_hours >= 0
AND laboratory_academic_hours >= 0
@@ -495,11 +620,14 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
lecture_start_week > 0
AND laboratory_start_week > 0
AND practice_start_week > 0
)
),
CONSTRAINT chk_schedule_rules_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
);
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rules_status_dates
ON schedule_rules(status, valid_from, valid_to);
CREATE TABLE IF NOT EXISTS schedule_rule_groups (
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
@@ -519,6 +647,12 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
lesson_format VARCHAR(30) NOT NULL,
time_locked BOOLEAN NOT NULL DEFAULT FALSE,
classroom_locked BOOLEAN NOT NULL DEFAULT FALSE,
teacher_locked BOOLEAN NOT NULL DEFAULT FALSE,
locked_by BIGINT REFERENCES users(id),
locked_at TIMESTAMP,
lock_comment TEXT,
CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7),
CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн'))
@@ -537,6 +671,28 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);
CREATE TABLE IF NOT EXISTS schedule_overrides (
id BIGSERIAL PRIMARY KEY,
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
action VARCHAR(20) NOT NULL,
new_time_slot_id BIGINT REFERENCES time_slots(id),
new_classroom_id BIGINT REFERENCES classrooms(id),
new_teacher_id BIGINT REFERENCES users(id),
new_lesson_format VARCHAR(30),
comment TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')),
CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date)
);
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
ON schedule_overrides(lesson_date);
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
ON schedule_overrides(new_teacher_id);
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
RETURNS TRIGGER AS $$
DECLARE
@@ -762,12 +918,17 @@ COMMENT ON TABLE schedule_rules IS 'Правила динамической ге
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре';
COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре';
COMMENT ON COLUMN schedule_rules.valid_from IS 'Дата начала действия версии правила расписания';
COMMENT ON COLUMN schedule_rules.valid_to IS 'Дата окончания действия версии правила расписания';
COMMENT ON TABLE education_forms IS 'Формы обучения';
COMMENT ON TABLE subgroups IS 'Подгруппы';
@@ -782,6 +943,7 @@ COMMENT ON COLUMN users.id IS 'ID пользователя';
COMMENT ON COLUMN users.username IS 'Логин пользователя';
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
COMMENT ON COLUMN users.role IS 'Роль пользователя';
COMMENT ON COLUMN users.status IS 'Жизненный цикл пользователя: ACTIVE или ARCHIVED';
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
@@ -833,6 +995,7 @@ COMMENT ON COLUMN classrooms.floor IS 'Этаж';
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN classrooms.status IS 'Жизненный цикл аудитории: ACTIVE или ARCHIVED';
COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';