баг-фикс 30/34
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
|
||||
*/
|
||||
@Component
|
||||
public class ClientIpResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
|
||||
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
|
||||
private static final int MAX_PROXY_HOPS = 20;
|
||||
private static final String UNKNOWN_ADDRESS = "неизвестен";
|
||||
|
||||
private final List<CidrRange> trustedProxyRanges;
|
||||
|
||||
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
|
||||
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
|
||||
}
|
||||
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
|
||||
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
|
||||
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded == null || forwarded.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String[] hops = forwarded.split(",", -1);
|
||||
if (hops.length > MAX_PROXY_HOPS) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
InetAddress leftmost = null;
|
||||
for (int index = hops.length - 1; index >= 0; index--) {
|
||||
Optional<InetAddress> hop = parseAddress(hops[index].trim());
|
||||
if (hop.isEmpty()) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
|
||||
return fallback;
|
||||
}
|
||||
leftmost = hop.get();
|
||||
if (!isTrusted(hop.get())) {
|
||||
return hop.get().getHostAddress();
|
||||
}
|
||||
}
|
||||
return leftmost == null ? fallback : leftmost.getHostAddress();
|
||||
}
|
||||
|
||||
private boolean isTrusted(InetAddress address) {
|
||||
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
|
||||
}
|
||||
|
||||
private List<CidrRange> parseTrustedRanges(String rawRanges) {
|
||||
if (rawRanges == null || rawRanges.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CidrRange> ranges = new ArrayList<>();
|
||||
for (String rawRange : rawRanges.split(",")) {
|
||||
String value = rawRange.trim();
|
||||
if (!value.isEmpty()) {
|
||||
ranges.add(CidrRange.parse(value));
|
||||
}
|
||||
}
|
||||
return List.copyOf(ranges);
|
||||
}
|
||||
|
||||
private static Optional<InetAddress> parseAddress(String rawAddress) {
|
||||
if (rawAddress == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(InetAddress.getByName(value));
|
||||
} catch (UnknownHostException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private record CidrRange(byte[] network, int prefixLength) {
|
||||
|
||||
static CidrRange parse(String value) {
|
||||
String[] parts = value.split("/", -1);
|
||||
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
|
||||
if (address.isEmpty()) {
|
||||
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
|
||||
}
|
||||
int addressBits = address.get().getAddress().length * 8;
|
||||
int prefix;
|
||||
try {
|
||||
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
|
||||
}
|
||||
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
|
||||
}
|
||||
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
|
||||
}
|
||||
|
||||
boolean contains(InetAddress candidate) {
|
||||
byte[] candidateBytes = candidate.getAddress();
|
||||
return candidateBytes.length == network.length
|
||||
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
|
||||
}
|
||||
|
||||
private static byte[] mask(byte[] address, int prefix) {
|
||||
byte[] masked = Arrays.copyOf(address, address.length);
|
||||
int fullBytes = prefix / 8;
|
||||
int remainingBits = prefix % 8;
|
||||
if (fullBytes < masked.length && remainingBits > 0) {
|
||||
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
|
||||
fullBytes++;
|
||||
}
|
||||
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
|
||||
@Component
|
||||
public class LoginAttemptAuditCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public LoginAttemptAuditCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
|
||||
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
|
||||
batchSize, maxBatchesPerTenant, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(routingDataSource);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
|
||||
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, 0, true);
|
||||
}
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
|
||||
Instant now = clock.instant();
|
||||
Instant cutoff = now.minus(retention);
|
||||
int deletedAudits = 0;
|
||||
int deletedLimits = 0;
|
||||
int failedTenants = 0;
|
||||
for (String tenant : tenants.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedAudits += cleanupAudit(tenant, cutoff);
|
||||
deletedLimits += cleanupLimits(tenant, cutoff, now);
|
||||
} catch (RuntimeException failure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, failure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки аудита входа", failure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
if (deletedAudits + deletedLimits > 0) {
|
||||
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
|
||||
);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupAudit(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String tenant) {
|
||||
if (tenant == null || tenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int auditDeletedCount,
|
||||
int rateLimitDeletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.Normalizer;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class LoginRateLimitService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
|
||||
private static final String OUTCOME_FAILURE = "FAILURE";
|
||||
private static final String OUTCOME_BLOCKED = "BLOCKED";
|
||||
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginSecurityProperties properties;
|
||||
private final Clock clock;
|
||||
private final String dummyPasswordHash;
|
||||
|
||||
@Autowired
|
||||
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties) {
|
||||
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
|
||||
properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties,
|
||||
Clock clock) {
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
|
||||
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
|
||||
*/
|
||||
@Transactional
|
||||
public AuthenticationDecision authenticate(String tenant,
|
||||
String username,
|
||||
String rawPassword,
|
||||
String clientIp) {
|
||||
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
|
||||
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
|
||||
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
|
||||
Instant now = clock.instant();
|
||||
|
||||
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
|
||||
AuthLoginRateLimit state = rateLimitRepository
|
||||
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
|
||||
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
|
||||
|
||||
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
|
||||
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
|
||||
state.setFailureCount(0);
|
||||
state.setWindowStartedAt(now);
|
||||
state.setBlockedUntil(null);
|
||||
}
|
||||
|
||||
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
|
||||
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
|
||||
boolean passwordMatches = passwordEncoder.matches(
|
||||
rawPassword == null ? "" : rawPassword,
|
||||
storedHash
|
||||
);
|
||||
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
|
||||
|
||||
if (authenticated) {
|
||||
rateLimitRepository.delete(state);
|
||||
return AuthenticationDecision.authenticated(user.get());
|
||||
}
|
||||
|
||||
int failureCount = state.getFailureCount() + 1;
|
||||
state.setFailureCount(failureCount);
|
||||
state.setLastFailureAt(now);
|
||||
state.setUpdatedAt(now);
|
||||
|
||||
if (failureCount >= properties.getMaxFailures()) {
|
||||
Duration blockDuration = progressiveBlockDuration(failureCount);
|
||||
Instant blockedUntil = now.plus(blockDuration);
|
||||
state.setBlockedUntil(blockedUntil);
|
||||
long retryAfter = retryAfterSeconds(now, blockedUntil);
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
|
||||
return AuthenticationDecision.rejected();
|
||||
}
|
||||
|
||||
private Duration progressiveBlockDuration(int failureCount) {
|
||||
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
|
||||
long multiplier = 1L << exponent;
|
||||
Duration candidate;
|
||||
try {
|
||||
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
|
||||
} catch (ArithmeticException overflow) {
|
||||
return properties.getMaxBlockDuration();
|
||||
}
|
||||
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
|
||||
? properties.getMaxBlockDuration()
|
||||
: candidate;
|
||||
}
|
||||
|
||||
private void audit(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Long retryAfterSeconds) {
|
||||
Integer retryAfter = retryAfterSeconds == null
|
||||
? null
|
||||
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
|
||||
auditRepository.save(new AuthLoginAttemptAudit(
|
||||
tenant, username, clientIp, outcome, occurredAt, retryAfter
|
||||
));
|
||||
}
|
||||
|
||||
private void logDenied(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Long retryAfterSeconds) {
|
||||
log.warn(
|
||||
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
|
||||
tenant,
|
||||
fingerprint(username),
|
||||
clientIp,
|
||||
outcome,
|
||||
retryAfterSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeAndLimit(String value, int maxLength, String fallback) {
|
||||
String bounded = value == null
|
||||
? fallback
|
||||
: value.substring(0, Math.min(value.length(), maxLength * 4));
|
||||
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
|
||||
.replaceAll("\\p{C}", "")
|
||||
.strip()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (normalized.isBlank()) {
|
||||
normalized = fallback;
|
||||
}
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
|
||||
Duration remaining = Duration.between(now, blockedUntil);
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
|
||||
return Math.max(1, seconds);
|
||||
}
|
||||
|
||||
private String fingerprint(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
|
||||
|
||||
static AuthenticationDecision authenticated(User user) {
|
||||
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision rejected() {
|
||||
return new AuthenticationDecision(Status.REJECTED, null, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision blocked(long retryAfterSeconds) {
|
||||
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
AUTHENTICATED,
|
||||
REJECTED,
|
||||
BLOCKED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.login-security")
|
||||
public class LoginSecurityProperties {
|
||||
|
||||
private int maxFailures = 5;
|
||||
private Duration attemptWindow = Duration.ofMinutes(15);
|
||||
private Duration baseBlockDuration = Duration.ofMinutes(1);
|
||||
private Duration maxBlockDuration = Duration.ofMinutes(15);
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (maxFailures < 2 || maxFailures > 100) {
|
||||
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
|
||||
}
|
||||
requirePositive(attemptWindow, "Окно учёта попыток входа");
|
||||
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
|
||||
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
|
||||
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
|
||||
throw new IllegalStateException(
|
||||
"Максимальный срок блокировки входа не может быть меньше начального"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(Duration duration, String fieldName) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
throw new IllegalStateException(fieldName + " должен быть положительным");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxFailures() {
|
||||
return maxFailures;
|
||||
}
|
||||
|
||||
public void setMaxFailures(int maxFailures) {
|
||||
this.maxFailures = maxFailures;
|
||||
}
|
||||
|
||||
public Duration getAttemptWindow() {
|
||||
return attemptWindow;
|
||||
}
|
||||
|
||||
public void setAttemptWindow(Duration attemptWindow) {
|
||||
this.attemptWindow = attemptWindow;
|
||||
}
|
||||
|
||||
public Duration getBaseBlockDuration() {
|
||||
return baseBlockDuration;
|
||||
}
|
||||
|
||||
public void setBaseBlockDuration(Duration baseBlockDuration) {
|
||||
this.baseBlockDuration = baseBlockDuration;
|
||||
}
|
||||
|
||||
public Duration getMaxBlockDuration() {
|
||||
return maxBlockDuration;
|
||||
}
|
||||
|
||||
public void setMaxBlockDuration(Duration maxBlockDuration) {
|
||||
this.maxBlockDuration = maxBlockDuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
|
||||
*/
|
||||
@Component
|
||||
public class RefreshTokenCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
|
||||
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(
|
||||
routingDataSource,
|
||||
repository,
|
||||
enabled,
|
||||
retention,
|
||||
batchSize,
|
||||
maxBatchesPerTenant,
|
||||
Clock.systemUTC()
|
||||
);
|
||||
}
|
||||
|
||||
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(
|
||||
routingDataSource,
|
||||
"Маршрутизатор tenant-БД обязателен"
|
||||
);
|
||||
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
|
||||
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
|
||||
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
|
||||
*/
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, true);
|
||||
}
|
||||
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
|
||||
Instant cutoff = clock.instant().minus(retention);
|
||||
int deletedTotal = 0;
|
||||
int failedTenants = 0;
|
||||
|
||||
for (String tenant : tenantSnapshot.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedTotal += cleanupTenant(tenant, cutoff);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, cleanupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTotal > 0) {
|
||||
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
|
||||
tenantSnapshot.size(), deletedTotal, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupTenant(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
|
||||
tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String previousTenant) {
|
||||
if (previousTenant == null || previousTenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(previousTenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int deletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
@@ -25,17 +27,24 @@ public class RefreshTokenService {
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final JwtProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||
this(repository, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||
String refreshToken = generateRawToken();
|
||||
AuthRefreshToken token = new AuthRefreshToken();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
token.setUser(user);
|
||||
token.setTenant(tenant);
|
||||
token.setTokenHash(hashToken(refreshToken));
|
||||
@@ -53,7 +62,7 @@ public class RefreshTokenService {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
String currentHash = hashToken(rawToken);
|
||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
|
||||
if (storedOpt.isEmpty()) {
|
||||
@@ -101,7 +110,7 @@ public class RefreshTokenService {
|
||||
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||
.filter(token -> token.getRevokedAt() == null)
|
||||
.ifPresent(token -> {
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
token.setRevokedAt(clock.instant());
|
||||
repository.save(token);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class TenantDataSourceConfig {
|
||||
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||
);
|
||||
tenants.add(defaultTenant);
|
||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
||||
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
@@ -79,10 +79,10 @@ public class TenantDataSourceConfig {
|
||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
|
||||
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
|
||||
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||
|
||||
TenantConfig h2Fallback = new TenantConfig(
|
||||
@@ -155,7 +155,7 @@ public class TenantDataSourceConfig {
|
||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||
return new TenantConfigLoadResult(List.of(), true);
|
||||
@@ -189,15 +189,15 @@ public class TenantDataSourceConfig {
|
||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||
}
|
||||
closeDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
||||
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
|
||||
return true;
|
||||
} catch (Exception startupFailure) {
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationFailed(tenant);
|
||||
}
|
||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
||||
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
|
||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
||||
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (!activated) {
|
||||
@@ -211,9 +211,9 @@ public class TenantDataSourceConfig {
|
||||
try {
|
||||
hikariDataSource.close();
|
||||
} catch (RuntimeException closeFailure) {
|
||||
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
|
||||
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
|
||||
closeFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
|
||||
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
|
||||
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем, существует ли тенант
|
||||
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
|
||||
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
|
||||
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
|
||||
response.setStatus(404);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
||||
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
|
||||
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
try (Connection connection = tenantDataSource.get().getConnection()) {
|
||||
return connection.isValid(5);
|
||||
} catch (SQLException e) {
|
||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
|
||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
|
||||
normalizeDomain(domain), e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки подключения тенанта", e);
|
||||
return false;
|
||||
@@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
}
|
||||
return "Подключение не валидно";
|
||||
} catch (Exception e) {
|
||||
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
|
||||
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки внешнего подключения", e);
|
||||
return "Не удалось подключиться к базе данных";
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor {
|
||||
private final ExecutorService executor;
|
||||
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public TenantDatabaseHealthMonitor(
|
||||
TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicPeriodService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicPeriodService academicPeriodService;
|
||||
|
||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicPeriodService academicPeriodService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicPeriodService = academicPeriodService;
|
||||
}
|
||||
|
||||
@GetMapping("/years")
|
||||
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years")
|
||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, request);
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/years/{id}")
|
||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applyYear(year, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/years/{id}")
|
||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||
if (!academicYearRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
academicYearRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
academicPeriodService.deleteYear(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||
}
|
||||
|
||||
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years/{academicYearId}/semesters")
|
||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
|
||||
}
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
|
||||
}
|
||||
|
||||
@PutMapping("/semesters/{id}")
|
||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
||||
if (semester == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
|
||||
}
|
||||
|
||||
@GetMapping("/activity-types")
|
||||
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
|
||||
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
|
||||
}
|
||||
|
||||
private String validateYear(AcademicYearDto request) {
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
return "Название учебного года обязательно";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания учебного года обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateSemester(SemesterDto request) {
|
||||
if (request.semesterType() == null) {
|
||||
return "Тип семестра обязателен";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания семестра обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания семестра не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте код активности";
|
||||
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year, AcademicYearDto request) {
|
||||
year.setTitle(request.title().trim());
|
||||
year.setStartDate(request.startDate());
|
||||
year.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
|
||||
activityType.setCode(request.code().trim());
|
||||
activityType.setName(request.name().trim());
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -32,6 +33,7 @@ public class AcademicCalendarController {
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicCalendarGridService calendarGridService;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
@@ -42,7 +44,8 @@ public class AcademicCalendarController {
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicCalendarGridService calendarGridService,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
@@ -53,6 +56,7 @@ public class AcademicCalendarController {
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.calendarGridService = calendarGridService;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -85,17 +89,7 @@ public class AcademicCalendarController {
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
||||
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -103,14 +97,9 @@ public class AcademicCalendarController {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
|
||||
}
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/grid")
|
||||
|
||||
@@ -3,17 +3,18 @@ package com.magistr.app.controller;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.JwtProperties;
|
||||
import com.magistr.app.config.auth.JwtTokenService;
|
||||
import com.magistr.app.config.auth.ClientIpResolver;
|
||||
import com.magistr.app.config.auth.LoginRateLimitService;
|
||||
import com.magistr.app.config.auth.RefreshTokenRotation;
|
||||
import com.magistr.app.config.auth.RefreshTokenService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -27,8 +28,8 @@ import java.util.Optional;
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginRateLimitService loginRateLimitService;
|
||||
private final ClientIpResolver clientIpResolver;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final JwtProperties jwtProperties;
|
||||
@@ -42,13 +43,13 @@ public class AuthController {
|
||||
"STUDENT", "/student/"
|
||||
);
|
||||
|
||||
public AuthController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
public AuthController(LoginRateLimitService loginRateLimitService,
|
||||
ClientIpResolver clientIpResolver,
|
||||
JwtTokenService jwtTokenService,
|
||||
RefreshTokenService refreshTokenService,
|
||||
JwtProperties jwtProperties) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.loginRateLimitService = loginRateLimitService;
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.jwtProperties = jwtProperties;
|
||||
@@ -56,28 +57,46 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||
|
||||
if (userOpt.isEmpty() ||
|
||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Слишком много попыток входа. Повторите позже",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Неверное имя пользователя или пароль",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (user.isArchivedRecord()) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||
}
|
||||
User user = decision.user();
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||
String refreshToken = refreshTokenService.createSession(
|
||||
user,
|
||||
tenant,
|
||||
servletRequest.getHeader("User-Agent"),
|
||||
clientIp(servletRequest)
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
@@ -96,7 +115,7 @@ public class AuthController {
|
||||
refreshToken.get(),
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getHeader("User-Agent"),
|
||||
clientIp(request)
|
||||
clientIpResolver.resolve(request)
|
||||
);
|
||||
if (rotation.isEmpty()) {
|
||||
return unauthorizedWithClearedCookie();
|
||||
@@ -192,11 +211,4 @@ public class AuthController {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import org.hibernate.exception.ConstraintViolationException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
|
||||
*/
|
||||
final class DatabaseConstraintViolationMapper {
|
||||
|
||||
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
|
||||
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
|
||||
);
|
||||
|
||||
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
|
||||
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
|
||||
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
|
||||
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
|
||||
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
|
||||
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
|
||||
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
|
||||
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
|
||||
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
|
||||
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
|
||||
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
|
||||
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
|
||||
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
|
||||
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
|
||||
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
|
||||
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
|
||||
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
|
||||
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
|
||||
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
|
||||
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
|
||||
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
|
||||
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
|
||||
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
|
||||
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
|
||||
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
|
||||
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
|
||||
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
|
||||
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
|
||||
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
|
||||
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
|
||||
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
|
||||
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
|
||||
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
||||
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
||||
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
||||
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
||||
conflict("uq_calendar_days", "День календарного графика уже существует"),
|
||||
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
|
||||
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
|
||||
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
|
||||
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
|
||||
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
|
||||
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
|
||||
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
|
||||
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
|
||||
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
|
||||
);
|
||||
|
||||
ViolationResponse map(DataIntegrityViolationException exception) {
|
||||
String constraintName = constraintName(exception).orElse(null);
|
||||
String sqlState = sqlState(exception).orElse(null);
|
||||
if (constraintName != null) {
|
||||
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
|
||||
if (known != null) {
|
||||
return known.withDetails(constraintName, sqlState);
|
||||
}
|
||||
if (constraintName.endsWith("_fkey")) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("23502".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Не заполнено обязательное поле",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23514".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Данные нарушают правила предметной области",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23505".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Такая запись уже существует",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за конфликта или связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция нарушает ограничения целостности данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<String> constraintName(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof ConstraintViolationException constraintViolation
|
||||
&& constraintViolation.getConstraintName() != null) {
|
||||
return normalize(constraintViolation.getConstraintName());
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message != null) {
|
||||
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
|
||||
if (matcher.find()) {
|
||||
return normalize(matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> sqlState(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
|
||||
return Optional.of(sqlException.getSQLState());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Throwable nextCause(Throwable current) {
|
||||
Throwable cause = current.getCause();
|
||||
return cause == current ? null : cause;
|
||||
}
|
||||
|
||||
private Optional<String> normalize(String constraintName) {
|
||||
if (constraintName == null || constraintName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = constraintName.trim()
|
||||
.replace("\"", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
int dot = normalized.lastIndexOf('.');
|
||||
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
|
||||
}
|
||||
|
||||
record ViolationResponse(HttpStatus status,
|
||||
String message,
|
||||
String constraintName,
|
||||
String sqlState) {
|
||||
|
||||
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
|
||||
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -93,9 +92,8 @@ public class DepartmentController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при создании кафедры", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +146,8 @@ public class DepartmentController {
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.SubjectImportService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final SubjectImportService subjectImportService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService) {
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService) {
|
||||
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
|
||||
scheduleQueryService, teacherDepartmentService, subjectImportService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.subjectCommentRepository = subjectCommentRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.subjectImportService = subjectImportService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping("/subjects")
|
||||
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
|
||||
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();
|
||||
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Дисциплины загружены",
|
||||
"count", saved.size()
|
||||
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
||||
}
|
||||
SubjectComment comment = new SubjectComment();
|
||||
comment.setCreatedAt(businessTime.now());
|
||||
comment.setSubject(subject);
|
||||
comment.setComment(commentText.trim());
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
if (effectiveDepartmentId == null) {
|
||||
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(
|
||||
user,
|
||||
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
|
||||
user.getId(),
|
||||
businessTime.today()
|
||||
).orElse(user.getDepartmentId())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return teachersById.values().stream()
|
||||
.map(this::toUserResponse)
|
||||
return teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
|
||||
.map(user -> toUserResponse(user, effectiveDepartmentId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate today = businessTime.today();
|
||||
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
||||
}
|
||||
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
|
||||
}
|
||||
|
||||
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
||||
teacherRequest.setCreatedAt(businessTime.now());
|
||||
teacherRequest.setUpdatedAt(businessTime.now());
|
||||
teacherRequest.setDepartment(department);
|
||||
teacherRequest.setUsername(username);
|
||||
teacherRequest.setFullName(fullName);
|
||||
@@ -257,14 +275,14 @@ public class DepartmentWorkspaceController {
|
||||
);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
user.getDepartmentId()
|
||||
departmentId
|
||||
);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/education-forms")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class EducationFormController {
|
||||
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
@@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
@@ -20,6 +21,8 @@ import java.util.NoSuchElementException;
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
private final DatabaseConstraintViolationMapper constraintViolationMapper =
|
||||
new DatabaseConstraintViolationMapper();
|
||||
|
||||
@ExceptionHandler(ScheduleConflictException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
|
||||
@@ -58,6 +61,23 @@ public class GlobalExceptionHandler {
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
|
||||
DataIntegrityViolationException exception,
|
||||
HttpServletRequest request) {
|
||||
DatabaseConstraintViolationMapper.ViolationResponse violation =
|
||||
constraintViolationMapper.map(exception);
|
||||
log.warn(
|
||||
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
violation.constraintName() == null ? "не определено" : violation.constraintName(),
|
||||
violation.sqlState() == null ? "не определено" : violation.sqlState()
|
||||
);
|
||||
log.debug("Технические детали нарушения целостности данных", exception);
|
||||
return error(violation.status(), violation.message(), request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
@@ -67,7 +87,7 @@ public class GlobalExceptionHandler {
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now().toString());
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", reason(status));
|
||||
body.put("message", message);
|
||||
|
||||
@@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
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.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
@@ -17,12 +15,15 @@ import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -45,33 +46,49 @@ public class GroupController {
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this(groupRepository, educationFormRepository, specialtiesRepository,
|
||||
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
|
||||
scheduleGeneratorService, groupLifecycleService, academicStructureService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +99,7 @@ public class GroupController {
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
@@ -101,7 +118,7 @@ public class GroupController {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
@@ -163,10 +180,9 @@ public class GroupController {
|
||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e ) {
|
||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании группы", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,50 +190,9 @@ public class GroupController {
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||
try {
|
||||
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
|
||||
if (groupOpt.isEmpty()) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = groupOpt.get();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
|
||||
}
|
||||
StudentGroup group = academicStructureService.updateGroup(id, request);
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -229,7 +204,7 @@ public class GroupController {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.archive("Группа архивирована");
|
||||
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
|
||||
groupRepository.save(group);
|
||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||
@@ -278,11 +253,11 @@ public class GroupController {
|
||||
}
|
||||
|
||||
private GroupResponse mapToResponse(StudentGroup group) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||
LocalDate today = businessTime.today();
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
|
||||
Speciality speciality = group.getSpeciality();
|
||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||
LocalDate today = LocalDate.now();
|
||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||
return new GroupResponse(
|
||||
group.getId(),
|
||||
@@ -323,42 +298,9 @@ public class GroupController {
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||
@RequestBody GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
|
||||
}
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
|
||||
if (academicYear == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
|
||||
}
|
||||
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
|
||||
}
|
||||
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|
||||
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
|
||||
}
|
||||
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
scheduleGeneratorService.clearCache();
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
|
||||
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
|
||||
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||
.findByCalendarIdWithSubject(calendar.getId())
|
||||
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
|
||||
.stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList();
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -36,6 +35,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка специальностей");
|
||||
try {
|
||||
@@ -102,9 +102,8 @@ public class SpecialityController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при создании специальности", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +157,8 @@ public class SpecialityController {
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +192,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||
logger.info("Получен запрос на получение всех профилей обучения");
|
||||
return specialtyProfileRepository.findAll().stream()
|
||||
@@ -204,6 +203,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/{specialtyId}/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -255,14 +255,8 @@ public class SpecialityController {
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
|
||||
}
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
}
|
||||
|
||||
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -125,14 +124,10 @@ public class SubgroupController {
|
||||
if (deleteValidationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
||||
}
|
||||
try {
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
}
|
||||
|
||||
private String validate(SubgroupDto request) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -87,7 +86,7 @@ public class SubjectController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
|
||||
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
@@ -119,10 +118,9 @@ public class SubjectController {
|
||||
subject.getDepartmentId()
|
||||
)
|
||||
);
|
||||
} catch (Exception e){
|
||||
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании дисциплины", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
BCryptPasswordEncoder passwordEncoder) {
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setCreatedAt(businessTime.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
|
||||
request.setDepartment(department);
|
||||
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review.getReviewComment()));
|
||||
request.setCreatedTeacherId(teacher.getId());
|
||||
teacherCreationRequestRepository.save(request);
|
||||
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
|
||||
}
|
||||
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
||||
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository) {
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(teacherSubjectRepository, userRepository, subjectRepository,
|
||||
teacherDepartmentService, BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherSubjectRepository = teacherSubjectRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
|
||||
return departmentId != null
|
||||
&& teacher != null
|
||||
&& subject != null
|
||||
&& departmentId.equals(teacher.getDepartmentId())
|
||||
&& teacherDepartmentService.hasAssignmentAtDate(
|
||||
teacher.getId(),
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
)
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import com.magistr.app.service.TimeSlotService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -35,13 +34,16 @@ public class TimeSlotAdminController {
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final TimeSlotService timeSlotService;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
TimeSlotService timeSlotService) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.timeSlotService = timeSlotService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -151,83 +153,18 @@ public class TimeSlotAdminController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, null)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
||||
if (timeSlot == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||
return "Выберите сетку времени";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
return "Время начала должно быть раньше времени окончания";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
? request.durationMinutes()
|
||||
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
|
||||
timeSlot.setDurationMinutes(duration);
|
||||
}
|
||||
|
||||
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
|
||||
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
|
||||
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
|
||||
.isPresent();
|
||||
timeSlotService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
}
|
||||
|
||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||
|
||||
@@ -14,20 +14,20 @@ 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 com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@@ -38,16 +38,33 @@ public class UserController {
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(userRepository, passwordEncoder, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherDepartmentService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +99,10 @@ public class UserController {
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
|
||||
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -92,7 +112,7 @@ public class UserController {
|
||||
|
||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||
return ResponseEntity.ok(users.stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -161,7 +181,7 @@ public class UserController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(requestedDepartment);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -180,7 +200,7 @@ public class UserController {
|
||||
logger.info("Пользователь с ID - {} не найден", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.archive("Пользователь архивирован");
|
||||
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
|
||||
userRepository.save(user);
|
||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||
@@ -206,7 +226,11 @@ public class UserController {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser != null
|
||||
&& currentUser.role() == Role.DEPARTMENT
|
||||
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
|
||||
&& !teacherDepartmentService.hasAssignmentAtDate(
|
||||
id,
|
||||
currentUser.departmentId(),
|
||||
businessTime.today()
|
||||
)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
||||
}
|
||||
@@ -216,46 +240,13 @@ public class UserController {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
|
||||
id,
|
||||
request,
|
||||
businessTime.today(),
|
||||
AuthContext.currentUserId()
|
||||
);
|
||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||
}
|
||||
|
||||
@@ -268,16 +259,25 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
|
||||
.map(this::toUserResponse)
|
||||
LocalDate effectiveDate = date == null ? businessTime.today() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
String departmentName = user.getDepartmentId() == null
|
||||
Long departmentId = user.getRole() == Role.TEACHER
|
||||
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
|
||||
.orElse(user.getDepartmentId())
|
||||
: user.getDepartmentId();
|
||||
return toUserResponse(user, departmentId);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
String departmentName = departmentId == null
|
||||
? null
|
||||
: departmentRepository.findById(user.getDepartmentId())
|
||||
: departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
UserResponse response = new UserResponse(
|
||||
@@ -288,7 +288,7 @@ public class UserController {
|
||||
user.getJobTitle(),
|
||||
departmentName
|
||||
);
|
||||
response.setDepartmentId(user.getDepartmentId());
|
||||
response.setDepartmentId(departmentId);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
@@ -307,23 +307,6 @@ public class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
|
||||
if (assignments != null) {
|
||||
assignments.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
}
|
||||
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
if (directTeachers != null) {
|
||||
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
}
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
private boolean canAccessDepartment(Long departmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser == null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
@@ -7,11 +8,13 @@ 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 com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
@@ -27,17 +30,17 @@ public class WorkloadController {
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
|
||||
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@@ -46,19 +49,11 @@ public class WorkloadController {
|
||||
@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();
|
||||
});
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
@@ -67,8 +62,10 @@ public class WorkloadController {
|
||||
@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);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
|
||||
}
|
||||
|
||||
@@ -78,22 +75,20 @@ public class WorkloadController {
|
||||
@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);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, 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<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
|
||||
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
||||
.collect(Collectors.groupingBy(lesson -> {
|
||||
User teacher = teachersById.get(lesson.teacherId());
|
||||
return teacher == null ? 0L : teacher.getDepartmentId();
|
||||
}));
|
||||
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
|
||||
lesson,
|
||||
assignmentsByTeacher
|
||||
).orElse(0L)));
|
||||
|
||||
return byDepartment.entrySet().stream()
|
||||
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
||||
@@ -101,14 +96,76 @@ public class WorkloadController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> lesson.teacherId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
lesson -> new TeacherDepartmentKey(
|
||||
lesson.teacherId(),
|
||||
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> summary(
|
||||
entry.getKey().teacherId(),
|
||||
entry.getValue().get(0).teacherName(),
|
||||
entry.getKey().departmentId(),
|
||||
entry.getValue()
|
||||
))
|
||||
.sorted(Comparator
|
||||
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
|
||||
.thenComparing(
|
||||
WorkloadSummaryDto::departmentId,
|
||||
Comparator.nullsLast(Long::compareTo)
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
|
||||
List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
Set<Long> teacherIds = lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
return teacherDepartmentService.findAssignmentsForTeachersBetween(
|
||||
teacherIds,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<Long> departmentIdAtLesson(
|
||||
RenderedLessonDto lesson,
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
|
||||
) {
|
||||
if (lesson.teacherId() == null || lesson.date() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return teacherDepartmentService.resolveDepartmentIdAtDate(
|
||||
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
|
||||
lesson.date()
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
||||
lesson -> lesson.timeSlotOrder() == null
|
||||
? "Пара"
|
||||
@@ -121,8 +178,10 @@ public class WorkloadController {
|
||||
@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)
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(null);
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, timeSlotId, date, date
|
||||
)
|
||||
.stream()
|
||||
.map(RenderedLessonDto::classroomId)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -166,6 +225,9 @@ public class WorkloadController {
|
||||
if (departmentId == null) {
|
||||
return null;
|
||||
}
|
||||
if (departmentId == 0L) {
|
||||
return "Кафедра не указана";
|
||||
}
|
||||
return departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Кафедра не найдена");
|
||||
@@ -185,4 +247,35 @@ public class WorkloadController {
|
||||
new ArrayList<>(classroom.getEquipments())
|
||||
);
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"Не удалось определить текущего пользователя"
|
||||
);
|
||||
}
|
||||
if (currentUser.role() != Role.DEPARTMENT) {
|
||||
return requestedDepartmentId;
|
||||
}
|
||||
|
||||
Long ownDepartmentId = currentUser.departmentId();
|
||||
if (ownDepartmentId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Для пользователя кафедры не указана кафедра"
|
||||
);
|
||||
}
|
||||
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Нельзя просматривать нагрузку другой кафедры"
|
||||
);
|
||||
}
|
||||
return ownDepartmentId;
|
||||
}
|
||||
|
||||
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.Equipment;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class ClassroomResponse {
|
||||
@@ -12,7 +12,7 @@ public class ClassroomResponse {
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private String status;
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
private String archiveReason;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
@@ -25,7 +25,7 @@ public class ClassroomResponse {
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, String status, LocalDateTime archivedAt,
|
||||
Boolean isAvailable, String status, Instant archivedAt,
|
||||
String archiveReason, List<Equipment> equipments) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
@@ -95,11 +95,11 @@ public class ClassroomResponse {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ScheduleOverrideDto(
|
||||
Long id,
|
||||
@@ -14,6 +14,6 @@ public record ScheduleOverrideDto(
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record SubjectCommentDto(
|
||||
Long id,
|
||||
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
|
||||
Long authorId,
|
||||
String authorName,
|
||||
String comment,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record TeacherCreationRequestResponse(
|
||||
Long id,
|
||||
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
|
||||
String comment,
|
||||
String status,
|
||||
Long requestedBy,
|
||||
LocalDateTime createdAt,
|
||||
Instant createdAt,
|
||||
Long reviewedBy,
|
||||
LocalDateTime reviewedAt,
|
||||
Instant reviewedAt,
|
||||
String reviewComment,
|
||||
Long createdTeacherId
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_attempt_audit")
|
||||
public class AuthLoginAttemptAudit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private Instant occurredAt;
|
||||
|
||||
@Column(name = "retry_after_seconds")
|
||||
private Integer retryAfterSeconds;
|
||||
|
||||
public AuthLoginAttemptAudit() {
|
||||
}
|
||||
|
||||
public AuthLoginAttemptAudit(String tenant,
|
||||
String usernameNormalized,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Integer retryAfterSeconds) {
|
||||
this.tenant = tenant;
|
||||
this.usernameNormalized = usernameNormalized;
|
||||
this.clientIp = clientIp;
|
||||
this.outcome = outcome;
|
||||
this.occurredAt = occurredAt;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public String getTenant() {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public String getUsernameNormalized() {
|
||||
return usernameNormalized;
|
||||
}
|
||||
|
||||
public String getClientIp() {
|
||||
return clientIp;
|
||||
}
|
||||
|
||||
public String getOutcome() {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public Instant getOccurredAt() {
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
public Integer getRetryAfterSeconds() {
|
||||
return retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_rate_limits")
|
||||
public class AuthLoginRateLimit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(name = "failure_count", nullable = false)
|
||||
private int failureCount;
|
||||
|
||||
@Column(name = "window_started_at", nullable = false)
|
||||
private Instant windowStartedAt;
|
||||
|
||||
@Column(name = "last_failure_at")
|
||||
private Instant lastFailureAt;
|
||||
|
||||
@Column(name = "blocked_until")
|
||||
private Instant blockedUntil;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getFailureCount() {
|
||||
return failureCount;
|
||||
}
|
||||
|
||||
public void setFailureCount(int failureCount) {
|
||||
this.failureCount = failureCount;
|
||||
}
|
||||
|
||||
public Instant getWindowStartedAt() {
|
||||
return windowStartedAt;
|
||||
}
|
||||
|
||||
public void setWindowStartedAt(Instant windowStartedAt) {
|
||||
this.windowStartedAt = windowStartedAt;
|
||||
}
|
||||
|
||||
public Instant getLastFailureAt() {
|
||||
return lastFailureAt;
|
||||
}
|
||||
|
||||
public void setLastFailureAt(Instant lastFailureAt) {
|
||||
this.lastFailureAt = lastFailureAt;
|
||||
}
|
||||
|
||||
public Instant getBlockedUntil() {
|
||||
return blockedUntil;
|
||||
}
|
||||
|
||||
public void setBlockedUntil(Instant blockedUntil) {
|
||||
this.blockedUntil = blockedUntil;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_refresh_tokens")
|
||||
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
private Instant issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "rotated_to_token_hash", length = 64)
|
||||
private String rotatedToTokenHash;
|
||||
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getIssuedAt() {
|
||||
public Instant getIssuedAt() {
|
||||
return issuedAt;
|
||||
}
|
||||
|
||||
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||
public void setIssuedAt(Instant issuedAt) {
|
||||
this.issuedAt = issuedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getRevokedAt() {
|
||||
public Instant getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||
public void setRevokedAt(Instant revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
public boolean isActive(LocalDateTime now) {
|
||||
public boolean isActive(Instant now) {
|
||||
return revokedAt == null && expiresAt.isAfter(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@MappedSuperclass
|
||||
public abstract class LifecycleEntity {
|
||||
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
|
||||
private LocalDate activeTo;
|
||||
|
||||
@Column(name = "archived_at")
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
|
||||
@Column(name = "archive_reason")
|
||||
private String archiveReason;
|
||||
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
|
||||
this.activeTo = activeTo;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
|
||||
}
|
||||
|
||||
public void archive(String reason) {
|
||||
archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now());
|
||||
}
|
||||
|
||||
public void archive(String reason, LocalDate businessDate, Instant archiveMoment) {
|
||||
status = STATUS_ARCHIVED;
|
||||
archivedAt = LocalDateTime.now();
|
||||
activeTo = LocalDate.now();
|
||||
archivedAt = archiveMoment;
|
||||
activeTo = businessDate;
|
||||
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_overrides")
|
||||
@@ -45,7 +45,7 @@ public class ScheduleOverride {
|
||||
private Long createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -123,11 +123,11 @@ public class ScheduleOverride {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 200)
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(name = "code")
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subject_comments")
|
||||
@@ -24,7 +24,7 @@ public class SubjectComment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -54,11 +54,11 @@ public class SubjectComment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_creation_requests")
|
||||
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
|
||||
private Long requestedBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt = LocalDateTime.now();
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private Long reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_at")
|
||||
private LocalDateTime reviewedAt;
|
||||
private Instant reviewedAt;
|
||||
|
||||
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||
private String reviewComment;
|
||||
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
|
||||
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
|
||||
this.requestedBy = requestedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
|
||||
this.reviewedBy = reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedAt() {
|
||||
public Instant getReviewedAt() {
|
||||
return reviewedAt;
|
||||
}
|
||||
|
||||
public void setReviewedAt(LocalDateTime reviewedAt) {
|
||||
public void setReviewedAt(Instant reviewedAt) {
|
||||
this.reviewedAt = reviewedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_department_assignments")
|
||||
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select day
|
||||
from AcademicCalendarDay day
|
||||
join fetch day.academicCalendar
|
||||
join fetch day.activityType
|
||||
where day.academicCalendar.id in :calendarIds
|
||||
and day.date between :startDate and :endDate
|
||||
order by day.academicCalendar.id, day.courseNumber, day.date
|
||||
""")
|
||||
List<AcademicCalendarDay> findForScheduleBatch(
|
||||
@Param("calendarIds") Collection<Long> calendarIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
boolean existsByActivityTypeId(Long activityTypeId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
|
||||
|
||||
@Query("""
|
||||
select case when count(day) > 0 then true else false end
|
||||
from AcademicCalendarDay day
|
||||
where day.academicCalendar.id = :calendarId
|
||||
and (day.date < :startDate or day.date > :endDate)
|
||||
""")
|
||||
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Modifying
|
||||
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
|
||||
void deleteByCalendarId(@Param("calendarId") Long calendarId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select calendar
|
||||
from AcademicCalendar calendar
|
||||
join fetch calendar.academicYear
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
|
||||
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||
|
||||
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
||||
|
||||
Optional<AcademicYear> findByTitle(String title);
|
||||
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
boolean existsByTitleAndIdNot(String title, Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select year from AcademicYear year where year.id = :id")
|
||||
Optional<AcademicYear> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.id <> :excludedId
|
||||
and year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface AuthLoginAttemptAuditRepository extends JpaRepository<AuthLoginAttemptAudit, Long> {
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_attempt_audit
|
||||
WHERE occurred_at < :cutoff
|
||||
ORDER BY occurred_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_attempt_audit audit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE audit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthLoginRateLimitRepository extends JpaRepository<AuthLoginRateLimit, Long> {
|
||||
|
||||
@Modifying(flushAutomatically = true)
|
||||
@Query(value = """
|
||||
INSERT INTO auth_login_rate_limits (
|
||||
tenant, username_normalized, client_ip, failure_count,
|
||||
window_started_at, updated_at
|
||||
) VALUES (:tenant, :username, :clientIp, 0, :now, :now)
|
||||
ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING
|
||||
""", nativeQuery = true)
|
||||
int ensureExists(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp,
|
||||
@Param("now") Instant now);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
SELECT limit
|
||||
FROM AuthLoginRateLimit limit
|
||||
WHERE limit.tenant = :tenant
|
||||
AND limit.usernameNormalized = :username
|
||||
AND limit.clientIp = :clientIp
|
||||
""")
|
||||
Optional<AuthLoginRateLimit> findForUpdate(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_rate_limits
|
||||
WHERE updated_at < :cutoff
|
||||
AND (blocked_until IS NULL OR blocked_until <= :now)
|
||||
ORDER BY updated_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_rate_limits rate_limit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE rate_limit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteStaleBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("now") Instant now,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import com.magistr.app.model.AuthRefreshToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
||||
@@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshTok
|
||||
WHERE token.tokenHash = :tokenHash
|
||||
""")
|
||||
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
|
||||
|
||||
/**
|
||||
* Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким
|
||||
* backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_refresh_tokens
|
||||
WHERE expires_at < :cutoff
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < :cutoff)
|
||||
ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_refresh_tokens token
|
||||
USING cleanup_candidates candidate
|
||||
WHERE token.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
|
||||
@@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
List<StudentGroup> findByStatusNot(String status);
|
||||
|
||||
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select studentGroup
|
||||
from StudentGroup studentGroup
|
||||
join fetch studentGroup.educationForm
|
||||
join fetch studentGroup.speciality
|
||||
join fetch studentGroup.specialtyProfile
|
||||
where studentGroup.id = :id
|
||||
""")
|
||||
Optional<StudentGroup> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
@@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups matchingGroup
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where matchingGroup.id in :groupIds
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||
@Param("groupIds") Collection<Long> groupIds,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
|
||||
@@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSl
|
||||
|
||||
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
|
||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||
|
||||
boolean existsByTimeSlotId(Long timeSlotId);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
|
||||
|
||||
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select semester
|
||||
from Semester semester
|
||||
join fetch semester.academicYear
|
||||
where semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
order by semester.startDate, semester.id
|
||||
""")
|
||||
List<Semester> findOverlappingForSchedule(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
|
||||
|
||||
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
||||
|
||||
boolean existsByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
||||
|
||||
boolean existsByAcademicYearIdAndSemesterTypeAndIdNot(Long academicYearId,
|
||||
SemesterType semesterType,
|
||||
Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(semester) > 0 then true else false end
|
||||
from Semester semester
|
||||
where semester.academicYear.id = :academicYearId
|
||||
and semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(@Param("academicYearId") Long academicYearId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select case when count(semester) > 0 then true else false end
|
||||
from Semester semester
|
||||
where semester.academicYear.id = :academicYearId
|
||||
and semester.id <> :excludedId
|
||||
and semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("academicYearId") Long academicYearId,
|
||||
@Param("excludedId") Long excludedId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select semester from Semester semester where semester.id = :id")
|
||||
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Collection;
|
||||
|
||||
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
|
||||
|
||||
@@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("academicYearId") Long academicYearId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.studentGroup
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar
|
||||
where assignment.studentGroup.id in :groupIds
|
||||
and assignment.academicYear.id in :academicYearIds
|
||||
""")
|
||||
List<StudentGroupCalendarAssignment> findForScheduleBatch(
|
||||
@Param("groupIds") Collection<Long> groupIds,
|
||||
@Param("academicYearIds") Collection<Long> academicYearIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.studentGroup studentGroup
|
||||
join fetch studentGroup.educationForm
|
||||
join fetch studentGroup.speciality
|
||||
join fetch studentGroup.specialtyProfile
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar calendar
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where calendar.id = :calendarId
|
||||
order by assignment.id
|
||||
""")
|
||||
List<StudentGroupCalendarAssignment> findByCalendarIdWithDetails(@Param("calendarId") Long calendarId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar calendar
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where assignment.studentGroup.id = :groupId
|
||||
and assignment.academicYear.id = :academicYearId
|
||||
""")
|
||||
Optional<StudentGroupCalendarAssignment> findByGroupIdAndAcademicYearIdForUpdate(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("academicYearId") Long academicYearId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||
Optional<Subject> findByName(String name);
|
||||
Optional<Subject> findByNameIgnoreCase(String name);
|
||||
|
||||
List<Subject> findByDepartmentId(Long departmentId);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
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;
|
||||
import java.util.Set;
|
||||
|
||||
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
|
||||
|
||||
@@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.department
|
||||
where a.teacher.id = :teacherId
|
||||
and a.primaryAssignment = true
|
||||
and a.validFrom <= :date
|
||||
and (a.validTo is null or a.validTo >= :date)
|
||||
order by a.validFrom desc, a.id desc
|
||||
""")
|
||||
Optional<TeacherDepartmentAssignment> findPrimaryAtDate(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.department
|
||||
where a.teacher.id = :teacherId
|
||||
and a.primaryAssignment = true
|
||||
order by a.validFrom, a.id
|
||||
""")
|
||||
List<TeacherDepartmentAssignment> findPrimaryHistoryForUpdate(@Param("teacherId") Long teacherId);
|
||||
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.teacher
|
||||
join fetch a.department
|
||||
where a.teacher.id in :teacherIds
|
||||
and a.validFrom <= :endDate
|
||||
and (a.validTo is null or a.validTo >= :startDate)
|
||||
order by a.teacher.id, a.validFrom, a.id
|
||||
""")
|
||||
List<TeacherDepartmentAssignment> findForTeachersBetween(
|
||||
@Param("teacherIds") Set<Long> teacherIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||
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;
|
||||
@@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlot
|
||||
|
||||
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from TimeSlotDateAssignment assignment
|
||||
join fetch assignment.timeSlotScope
|
||||
where assignment.date between :startDate and :endDate
|
||||
order by assignment.date
|
||||
""")
|
||||
List<TimeSlotDateAssignment> findForScheduleRange(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
||||
|
||||
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
||||
|
||||
@Query("""
|
||||
select slot
|
||||
from TimeSlot slot
|
||||
join fetch slot.timeSlotScope
|
||||
order by slot.timeSlotScope.displayOrder, slot.orderNumber, slot.startTime
|
||||
""")
|
||||
List<TimeSlot> findAllForSchedule();
|
||||
|
||||
boolean existsByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
||||
|
||||
boolean existsByTimeSlotScopeIdAndOrderNumberAndIdNot(Long scopeId, Integer orderNumber, Long id);
|
||||
|
||||
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select slot from TimeSlot slot where slot.id = :id")
|
||||
Optional<TimeSlot> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(slot) > 0 then true else false end
|
||||
from TimeSlot slot
|
||||
where slot.timeSlotScope.id = :scopeId
|
||||
and slot.startTime < :endTime
|
||||
and slot.endTime > :startTime
|
||||
""")
|
||||
boolean existsOverlapping(@Param("scopeId") Long scopeId,
|
||||
@Param("startTime") LocalTime startTime,
|
||||
@Param("endTime") LocalTime endTime);
|
||||
|
||||
@Query("""
|
||||
select case when count(slot) > 0 then true else false end
|
||||
from TimeSlot slot
|
||||
where slot.timeSlotScope.id = :scopeId
|
||||
and slot.id <> :excludedId
|
||||
and slot.startTime < :endTime
|
||||
and slot.endTime > :startTime
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("scopeId") Long scopeId,
|
||||
@Param("excludedId") Long excludedId,
|
||||
@Param("startTime") LocalTime startTime,
|
||||
@Param("endTime") LocalTime endTime);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Lo
|
||||
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||
|
||||
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
|
||||
|
||||
List<TimeSlotScope> findByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select scope from TimeSlotScope scope where scope.id = :id")
|
||||
Optional<TimeSlotScope> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
List<User> findByRoleAndStatusNot(Role role, String status);
|
||||
|
||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select user from User user where user.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AcademicDateService {
|
||||
@@ -33,6 +35,64 @@ public class AcademicDateService {
|
||||
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
|
||||
}
|
||||
|
||||
public List<Semester> findSemesters(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||
return List.of();
|
||||
}
|
||||
return semesterRepository.findOverlappingForSchedule(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает календарные данные одним набором batch-запросов и возвращает снимок,
|
||||
* который не обращается к репозиториям во внутренних циклах генератора.
|
||||
*/
|
||||
public ScheduleSnapshot createScheduleSnapshot(Collection<StudentGroup> groups,
|
||||
Collection<Semester> semesters,
|
||||
LocalDate endDate) {
|
||||
List<Semester> semesterSnapshot = Optional.ofNullable(semesters)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator.comparing(Semester::getStartDate).thenComparing(Semester::getId))
|
||||
.toList();
|
||||
LocalDate snapshotStart = semesterSnapshot.stream()
|
||||
.map(Semester::getStartDate)
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(endDate);
|
||||
|
||||
Set<Long> groupIds = Optional.ofNullable(groups)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(StudentGroup::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> academicYearIds = semesterSnapshot.stream()
|
||||
.map(Semester::getAcademicYear)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AcademicYear::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
|
||||
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
|
||||
? List.of()
|
||||
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
|
||||
Set<Long> calendarIds = assignments.stream()
|
||||
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AcademicCalendar::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|
||||
|| snapshotStart == null
|
||||
|| endDate == null
|
||||
|| endDate.isBefore(snapshotStart)
|
||||
? List.of()
|
||||
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
|
||||
|
||||
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate);
|
||||
}
|
||||
|
||||
public int getWeekNumber(Semester semester, LocalDate date) {
|
||||
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
|
||||
if (days < 0) {
|
||||
@@ -86,4 +146,128 @@ public class AcademicDateService {
|
||||
}
|
||||
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent();
|
||||
}
|
||||
|
||||
public static final class ScheduleSnapshot {
|
||||
|
||||
private final Map<LocalDate, Semester> semesterByDate;
|
||||
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
|
||||
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate;
|
||||
private final LocalDate startDate;
|
||||
private final LocalDate endDate;
|
||||
|
||||
private ScheduleSnapshot(Collection<Semester> semesters,
|
||||
Collection<StudentGroupCalendarAssignment> assignments,
|
||||
Collection<AcademicCalendarDay> calendarDays,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.semesterByDate = indexSemesters(semesters, startDate, endDate);
|
||||
this.assignmentByGroupAndYear = assignments.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
assignment -> new GroupAcademicYearKey(
|
||||
assignment.getStudentGroup().getId(),
|
||||
assignment.getAcademicYear().getId()
|
||||
),
|
||||
Function.identity()
|
||||
));
|
||||
this.activityByCalendarCourseAndDate = calendarDays.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
day -> new CalendarDayKey(
|
||||
day.getAcademicCalendar().getId(),
|
||||
day.getCourseNumber(),
|
||||
day.getDate()
|
||||
),
|
||||
AcademicCalendarDay::getActivityType
|
||||
));
|
||||
}
|
||||
|
||||
public Optional<Semester> findSemester(LocalDate date) {
|
||||
return Optional.ofNullable(semesterByDate.get(date));
|
||||
}
|
||||
|
||||
public boolean hasCalendarAssignment(StudentGroup group, Semester semester) {
|
||||
return findAssignment(group, semester).isPresent();
|
||||
}
|
||||
|
||||
public Optional<AcademicCalendarActivityType> getActivityType(StudentGroup group,
|
||||
Semester semester,
|
||||
LocalDate date) {
|
||||
int courseNumber = courseNumber(group, date);
|
||||
if (courseNumber < 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return findAssignment(group, semester)
|
||||
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||
.map(AcademicCalendar::getId)
|
||||
.map(calendarId -> activityByCalendarCourseAndDate.get(
|
||||
new CalendarDayKey(calendarId, courseNumber, date)
|
||||
));
|
||||
}
|
||||
|
||||
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
|
||||
return getActivityType(group, semester, date)
|
||||
.map(AcademicCalendarActivityType::getAllowSchedule)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public LocalDate endDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
private Optional<StudentGroupCalendarAssignment> findAssignment(StudentGroup group, Semester semester) {
|
||||
if (group == null
|
||||
|| group.getId() == null
|
||||
|| semester == null
|
||||
|| semester.getAcademicYear() == null
|
||||
|| semester.getAcademicYear().getId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(assignmentByGroupAndYear.get(
|
||||
new GroupAcademicYearKey(group.getId(), semester.getAcademicYear().getId())
|
||||
));
|
||||
}
|
||||
|
||||
private static Map<LocalDate, Semester> indexSemesters(Collection<Semester> semesters,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<LocalDate, Semester> result = new HashMap<>();
|
||||
for (Semester semester : semesters) {
|
||||
LocalDate effectiveStart = semester.getStartDate().isAfter(startDate)
|
||||
? semester.getStartDate()
|
||||
: startDate;
|
||||
LocalDate effectiveEnd = semester.getEndDate().isBefore(endDate)
|
||||
? semester.getEndDate()
|
||||
: endDate;
|
||||
for (LocalDate date = effectiveStart; !date.isAfter(effectiveEnd); date = date.plusDays(1)) {
|
||||
Semester previous = result.putIfAbsent(date, semester);
|
||||
if (previous != null && !Objects.equals(previous.getId(), semester.getId())) {
|
||||
throw new IllegalStateException("На одну дату назначено несколько семестров");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
private static int courseNumber(StudentGroup group, LocalDate date) {
|
||||
if (group == null || date == null || group.getYearStartStudy() == null) {
|
||||
return 0;
|
||||
}
|
||||
int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
|
||||
return academicYearStart - group.getYearStartStudy() + 1;
|
||||
}
|
||||
|
||||
private record GroupAcademicYearKey(Long groupId, Long academicYearId) {
|
||||
}
|
||||
|
||||
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicYearDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicPeriodService {
|
||||
|
||||
private static final String YEAR_CONFLICT_MESSAGE =
|
||||
"Название или период учебного года конфликтует с существующим учебным годом";
|
||||
private static final String SEMESTER_CONFLICT_MESSAGE =
|
||||
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear createYear(AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
String title = request.title().trim();
|
||||
if (academicYearRepository.existsByTitle(title)
|
||||
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
AcademicYear year = lockYear(id);
|
||||
String title = request.title().trim();
|
||||
|
||||
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
||||
|| academicYearRepository.existsOverlappingExcluding(
|
||||
id, request.startDate(), request.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
boolean semesterOutsideNewRange = semesterRepository
|
||||
.findByAcademicYearIdOrderByStartDateAsc(id)
|
||||
.stream()
|
||||
.anyMatch(semester -> !contains(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
));
|
||||
if (semesterOutsideNewRange) {
|
||||
throw new IllegalArgumentException(
|
||||
"Новые границы учебного года не включают все его семестры"
|
||||
);
|
||||
}
|
||||
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteYear(Long id) {
|
||||
AcademicYear year = lockYear(id);
|
||||
academicYearRepository.delete(year);
|
||||
academicYearRepository.flush();
|
||||
clearScheduleCacheAfterCommit();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Semester createSemester(Long academicYearId, SemesterDto request) {
|
||||
validateSemester(request);
|
||||
AcademicYear year = lockYear(academicYearId);
|
||||
validateSemesterWithinYear(year, request.startDate(), request.endDate());
|
||||
ensureSemesterAvailable(year.getId(), request, null);
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Semester updateSemester(Long id, SemesterDto request) {
|
||||
validateSemester(request);
|
||||
Semester snapshot = semesterRepository.findById(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
|
||||
AcademicYear year = lockYear(snapshot.getAcademicYear().getId());
|
||||
Semester semester = semesterRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
|
||||
if (!semester.getAcademicYear().getId().equals(year.getId())) {
|
||||
throw new ScheduleConflictException("Учебный год семестра был изменён конкурентно");
|
||||
}
|
||||
|
||||
validateSemesterWithinYear(year, request.startDate(), request.endDate());
|
||||
ensureSemesterAvailable(year.getId(), request, id);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void validateYear(AcademicYearDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные учебного года обязательны");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название учебного года обязательно");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания учебного года обязательны",
|
||||
"Дата окончания учебного года не может быть раньше даты начала"
|
||||
);
|
||||
}
|
||||
|
||||
private void validateSemester(SemesterDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные семестра обязательны");
|
||||
}
|
||||
if (request.semesterType() == null) {
|
||||
throw new IllegalArgumentException("Тип семестра обязателен");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания семестра обязательны",
|
||||
"Дата окончания семестра не может быть раньше даты начала"
|
||||
);
|
||||
}
|
||||
|
||||
private void validateDateRange(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String requiredMessage,
|
||||
String orderMessage) {
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalArgumentException(requiredMessage);
|
||||
}
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException(orderMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSemesterWithinYear(AcademicYear year,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (!contains(year.getStartDate(), year.getEndDate(), startDate, endDate)) {
|
||||
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean contains(LocalDate outerStart,
|
||||
LocalDate outerEnd,
|
||||
LocalDate innerStart,
|
||||
LocalDate innerEnd) {
|
||||
return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd);
|
||||
}
|
||||
|
||||
private void ensureSemesterAvailable(Long academicYearId, SemesterDto request, Long currentId) {
|
||||
boolean duplicateType = currentId == null
|
||||
? semesterRepository.existsByAcademicYearIdAndSemesterType(
|
||||
academicYearId, request.semesterType())
|
||||
: semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
|
||||
academicYearId, request.semesterType(), currentId);
|
||||
boolean overlapping = currentId == null
|
||||
? semesterRepository.existsOverlapping(
|
||||
academicYearId, request.startDate(), request.endDate())
|
||||
: semesterRepository.existsOverlappingExcluding(
|
||||
academicYearId, currentId, request.startDate(), request.endDate());
|
||||
if (duplicateType || overlapping) {
|
||||
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private AcademicYear lockYear(Long id) {
|
||||
return academicYearRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебный год не найден"));
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year,
|
||||
String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
year.setTitle(title);
|
||||
year.setStartDate(startDate);
|
||||
year.setEndDate(endDate);
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private AcademicYear saveYear(AcademicYear year) {
|
||||
try {
|
||||
return academicYearRepository.saveAndFlush(year);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private Semester saveSemester(Semester semester) {
|
||||
try {
|
||||
return semesterRepository.saveAndFlush(semester);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearScheduleCacheAfterCommit() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()
|
||||
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
scheduleGeneratorService.clearCache();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
scheduleGeneratorService.clearCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicStructureService {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
GroupRepository groupRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
||||
validateCalendarRequest(request);
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
|
||||
CalendarDimensions candidate = resolveCalendarDimensions(request);
|
||||
|
||||
List<StudentGroupCalendarAssignment> assignments =
|
||||
assignmentRepository.findByCalendarIdWithDetails(id);
|
||||
boolean incompatibleAssignment = assignments.stream()
|
||||
.anyMatch(assignment -> !isCompatible(
|
||||
assignment.getStudentGroup(),
|
||||
assignment.getAcademicYear(),
|
||||
candidate.academicYear(),
|
||||
candidate.speciality(),
|
||||
candidate.profile(),
|
||||
candidate.studyForm(),
|
||||
request.courseCount()
|
||||
));
|
||||
if (incompatibleAssignment) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
id, request.courseCount())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
|
||||
);
|
||||
}
|
||||
if (calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
|
||||
id, request.courseCount() * 2)) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsOutsideDateRange(
|
||||
id,
|
||||
candidate.academicYear().getStartDate(),
|
||||
candidate.academicYear().getEndDate())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить учебный год графика: сохранённая сетка содержит даты за его границами"
|
||||
);
|
||||
}
|
||||
|
||||
calendar.setTitle(request.title().trim());
|
||||
applyCalendarDimensions(calendar, candidate, request.courseCount());
|
||||
AcademicCalendar saved = saveCalendar(calendar);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StudentGroup updateGroup(Long id, CreateGroupRequest request) {
|
||||
validateGroupRequest(request);
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
||||
GroupDimensions candidate = resolveGroupDimensions(request);
|
||||
|
||||
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||
.anyMatch(assignment -> !isCompatible(
|
||||
request.getYearStartStudy(),
|
||||
candidate.speciality(),
|
||||
candidate.profile(),
|
||||
candidate.studyForm(),
|
||||
assignment.getAcademicYear(),
|
||||
assignment.getAcademicCalendar()
|
||||
));
|
||||
if (incompatibleAssignment) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми"
|
||||
);
|
||||
}
|
||||
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(candidate.studyForm());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(candidate.speciality());
|
||||
group.setSpecialtyProfile(candidate.profile());
|
||||
StudentGroup saved = saveGroup(group);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StudentGroupCalendarAssignment saveAssignment(Long groupId,
|
||||
GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
throw new IllegalArgumentException("Учебный год и календарный график обязательны");
|
||||
}
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(groupId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(request.calendarId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Календарный график не найден"));
|
||||
AcademicYear academicYear = academicYearRepository.findByIdForUpdate(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
|
||||
if (!isCompatible(group, academicYear, calendar)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Календарный график не соответствует учебному году, параметрам или курсу группы"
|
||||
);
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdForUpdate(groupId, academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
try {
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.saveAndFlush(assignment);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Назначение календарного графика было изменено конкурентно"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCalendarRequest(AcademicCalendarDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название графика обязательно");
|
||||
}
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
if (request.courseCount() > 8) {
|
||||
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
|
||||
}
|
||||
if (request.academicYearId() == null
|
||||
|| request.specialtyId() == null
|
||||
|| request.specialtyProfileId() == null
|
||||
|| request.studyFormId() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Учебный год, специальность, профиль и форма обучения обязательны"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGroupRequest(CreateGroupRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные учебной группы");
|
||||
}
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Название группы обязательно");
|
||||
}
|
||||
if (request.getGroupSize() == null) {
|
||||
throw new IllegalArgumentException("Численность группы обязательна");
|
||||
}
|
||||
if (request.getEducationFormId() == null) {
|
||||
throw new IllegalArgumentException("Форма обучения обязательна");
|
||||
}
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
throw new IllegalArgumentException("ID кафедры обязателен");
|
||||
}
|
||||
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
||||
throw new IllegalArgumentException("Год начала обучения обязателен");
|
||||
}
|
||||
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
||||
throw new IllegalArgumentException("Код специальности обязателен");
|
||||
}
|
||||
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
||||
throw new IllegalArgumentException("Профиль обучения обязателен");
|
||||
}
|
||||
}
|
||||
|
||||
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = resolveProfile(request.specialtyProfileId(), speciality);
|
||||
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
return new CalendarDimensions(academicYear, speciality, profile, studyForm);
|
||||
}
|
||||
|
||||
private GroupDimensions resolveGroupDimensions(CreateGroupRequest request) {
|
||||
EducationForm studyForm = educationFormRepository.findById(request.getEducationFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = resolveProfile(request.getSpecialtyProfileId(), speciality);
|
||||
return new GroupDimensions(speciality, profile, studyForm);
|
||||
}
|
||||
|
||||
private SpecialtyProfile resolveProfile(Long profileId, Speciality speciality) {
|
||||
SpecialtyProfile profile = profileRepository.findById(profileId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Профиль обучения не найден для выбранной специальности"
|
||||
));
|
||||
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Профиль обучения не найден для выбранной специальности"
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private boolean isCompatible(StudentGroup group,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicCalendar calendar) {
|
||||
return isCompatible(
|
||||
group,
|
||||
assignmentYear,
|
||||
calendar.getAcademicYear(),
|
||||
calendar.getSpeciality(),
|
||||
calendar.getSpecialtyProfile(),
|
||||
calendar.getStudyForm(),
|
||||
calendar.getCourseCount()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(StudentGroup group,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicYear calendarYear,
|
||||
Speciality calendarSpeciality,
|
||||
SpecialtyProfile calendarProfile,
|
||||
EducationForm calendarStudyForm,
|
||||
Integer courseCount) {
|
||||
return isCompatible(
|
||||
group.getYearStartStudy(),
|
||||
group.getSpeciality(),
|
||||
group.getSpecialtyProfile(),
|
||||
group.getEducationForm(),
|
||||
assignmentYear,
|
||||
calendarYear,
|
||||
calendarSpeciality,
|
||||
calendarProfile,
|
||||
calendarStudyForm,
|
||||
courseCount
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(Integer yearStartStudy,
|
||||
Speciality groupSpeciality,
|
||||
SpecialtyProfile groupProfile,
|
||||
EducationForm groupStudyForm,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicCalendar calendar) {
|
||||
return isCompatible(
|
||||
yearStartStudy,
|
||||
groupSpeciality,
|
||||
groupProfile,
|
||||
groupStudyForm,
|
||||
assignmentYear,
|
||||
calendar.getAcademicYear(),
|
||||
calendar.getSpeciality(),
|
||||
calendar.getSpecialtyProfile(),
|
||||
calendar.getStudyForm(),
|
||||
calendar.getCourseCount()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(Integer yearStartStudy,
|
||||
Speciality groupSpeciality,
|
||||
SpecialtyProfile groupProfile,
|
||||
EducationForm groupStudyForm,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicYear calendarYear,
|
||||
Speciality calendarSpeciality,
|
||||
SpecialtyProfile calendarProfile,
|
||||
EducationForm calendarStudyForm,
|
||||
Integer courseCount) {
|
||||
if (!sameId(assignmentYear, calendarYear)
|
||||
|| !sameId(groupSpeciality, calendarSpeciality)
|
||||
|| !sameId(groupProfile, calendarProfile)
|
||||
|| !sameId(groupStudyForm, calendarStudyForm)) {
|
||||
return false;
|
||||
}
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(
|
||||
yearStartStudy,
|
||||
assignmentYear.getStartDate()
|
||||
);
|
||||
return course >= 1 && courseCount != null && course <= courseCount;
|
||||
}
|
||||
|
||||
private boolean sameId(AcademicYear first, AcademicYear second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(Speciality first, Speciality second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(SpecialtyProfile first, SpecialtyProfile second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(EducationForm first, EducationForm second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private void applyCalendarDimensions(AcademicCalendar calendar,
|
||||
CalendarDimensions dimensions,
|
||||
Integer courseCount) {
|
||||
calendar.setAcademicYear(dimensions.academicYear());
|
||||
calendar.setSpeciality(dimensions.speciality());
|
||||
calendar.setSpecialtyProfile(dimensions.profile());
|
||||
calendar.setStudyForm(dimensions.studyForm());
|
||||
calendar.setCourseCount(courseCount);
|
||||
}
|
||||
|
||||
private AcademicCalendar saveCalendar(AcademicCalendar calendar) {
|
||||
try {
|
||||
return calendarRepository.saveAndFlush(calendar);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Изменение календарного графика конфликтует с сохранёнными данными"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private StudentGroup saveGroup(StudentGroup group) {
|
||||
try {
|
||||
return groupRepository.saveAndFlush(group);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Изменение учебной группы конфликтует с сохранёнными данными"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearScheduleCacheAfterCommit() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()
|
||||
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
scheduleGeneratorService.clearCache();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
scheduleGeneratorService.clearCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record CalendarDimensions(AcademicYear academicYear,
|
||||
Speciality speciality,
|
||||
SpecialtyProfile profile,
|
||||
EducationForm studyForm) {
|
||||
}
|
||||
|
||||
private record GroupDimensions(Speciality speciality,
|
||||
SpecialtyProfile profile,
|
||||
EducationForm studyForm) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.zone.ZoneRulesException;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Единый источник бизнес-даты и абсолютного времени приложения. */
|
||||
@Service
|
||||
public class BusinessTimeService {
|
||||
|
||||
public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow");
|
||||
|
||||
private final Clock clock;
|
||||
private final ZoneId zoneId;
|
||||
|
||||
@Autowired
|
||||
public BusinessTimeService(@Value("${app.time.business-zone:Europe/Moscow}") String zoneId) {
|
||||
this(Clock.systemUTC(), parseZone(zoneId));
|
||||
}
|
||||
|
||||
public BusinessTimeService(Clock clock, ZoneId zoneId) {
|
||||
this.clock = Objects.requireNonNull(clock, "Часы приложения обязательны");
|
||||
this.zoneId = Objects.requireNonNull(zoneId, "Часовой пояс бизнес-даты обязателен");
|
||||
}
|
||||
|
||||
public LocalDate today() {
|
||||
return LocalDate.ofInstant(clock.instant(), zoneId);
|
||||
}
|
||||
|
||||
public Instant now() {
|
||||
return clock.instant();
|
||||
}
|
||||
|
||||
public ZoneId zoneId() {
|
||||
return zoneId;
|
||||
}
|
||||
|
||||
public static BusinessTimeService systemDefault() {
|
||||
return new BusinessTimeService(Clock.systemUTC(), DEFAULT_ZONE);
|
||||
}
|
||||
|
||||
private static ZoneId parseZone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value == null || value.isBlank() ? DEFAULT_ZONE.getId() : value.trim());
|
||||
} catch (ZoneRulesException exception) {
|
||||
throw new IllegalStateException("Некорректный часовой пояс бизнес-даты: " + value, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
@@ -33,6 +34,7 @@ public class ScheduleGeneratorService {
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
@@ -41,6 +43,20 @@ public class ScheduleGeneratorService {
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
this(scheduleRuleRepository, groupRepository, userRepository, academicDateService,
|
||||
timeSlotRepository, timeSlotScopeRepository, dateAssignmentRepository,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
UserRepository userRepository,
|
||||
AcademicDateService academicDateService,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
BusinessTimeService businessTime) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -48,45 +64,134 @@ public class ScheduleGeneratorService {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
StudentGroup group = groupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||
return buildScheduleForGroupsInternal(List.of(group), startDate, endDate);
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroups(Collection<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
List<StudentGroup> groupSnapshot = Optional.ofNullable(groups)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (groups.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> semesterIds = semesters.stream()
|
||||
.map(Semester::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> groupIds = groups.stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester = indexRulesByGroupAndSemester(
|
||||
rules,
|
||||
groupIds
|
||||
);
|
||||
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
for (StudentGroup group : groups) {
|
||||
result.addAll(buildScheduleForGroup(
|
||||
group,
|
||||
startDate,
|
||||
endDate,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot,
|
||||
rulesByGroupAndSemester
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroup(StudentGroup group,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot,
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester) {
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
if (!academicDateService.hasCalendarAssignment(group, semester)
|
||||
if (!dateSnapshot.hasCalendarAssignment(group, semester)
|
||||
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
|
||||
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
|
||||
group.getName(), semester.getAcademicYear().getTitle());
|
||||
}
|
||||
if (!academicDateService.isTheoryDay(group, semester, date)) {
|
||||
if (!dateSnapshot.isTheoryDay(group, semester, date)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
||||
List<ScheduleRule> rules = rulesByGroupAndSemester.getOrDefault(
|
||||
new GroupSemesterKey(group.getId(), semester.getId()),
|
||||
List.of()
|
||||
);
|
||||
if (primedSemesterIds.add(semester.getId())) {
|
||||
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
|
||||
primeConsumedHoursBeforeRange(
|
||||
semester,
|
||||
rules,
|
||||
startDate,
|
||||
group,
|
||||
null,
|
||||
consumedHoursProgress,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
group,
|
||||
null,
|
||||
consumedHoursProgress,
|
||||
result,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,28 +204,68 @@ public class ScheduleGeneratorService {
|
||||
throw new IllegalArgumentException("Преподаватель не найден");
|
||||
}
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> semesterIds = semesters.stream()
|
||||
.map(Semester::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByTeacherIdAndSemesterIds(teacherId, semesterIds);
|
||||
List<StudentGroup> groups = rules.stream()
|
||||
.flatMap(rule -> rule.getGroups().stream())
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = rules.stream()
|
||||
.collect(Collectors.groupingBy(rule -> rule.getSemester().getId()));
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
||||
);
|
||||
List<ScheduleRule> semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of());
|
||||
if (primedSemesterIds.add(semester.getId())) {
|
||||
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
|
||||
primeConsumedHoursBeforeRange(
|
||||
semester,
|
||||
semesterRules,
|
||||
startDate,
|
||||
null,
|
||||
teacherId,
|
||||
consumedHoursProgress,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
|
||||
for (ScheduleRule rule : sortRules(semesterRules)) {
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
null,
|
||||
teacherId,
|
||||
consumedHoursProgress,
|
||||
result,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,12 +306,48 @@ public class ScheduleGeneratorService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<GroupSemesterKey, List<ScheduleRule>> indexRulesByGroupAndSemester(
|
||||
Collection<ScheduleRule> rules,
|
||||
Set<Long> requestedGroupIds
|
||||
) {
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> result = new HashMap<>();
|
||||
for (ScheduleRule rule : rules) {
|
||||
Long semesterId = rule.getSemester().getId();
|
||||
for (StudentGroup group : rule.getGroups()) {
|
||||
if (!requestedGroupIds.contains(group.getId())) {
|
||||
continue;
|
||||
}
|
||||
result.computeIfAbsent(
|
||||
new GroupSemesterKey(group.getId(), semesterId),
|
||||
ignored -> new ArrayList<>()
|
||||
).add(rule);
|
||||
}
|
||||
}
|
||||
result.replaceAll((key, value) -> sortRules(value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private TimeSlotSnapshot loadTimeSlotSnapshot(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Collection<ScheduleRule> rules) {
|
||||
if (rules.isEmpty()) {
|
||||
return TimeSlotSnapshot.empty();
|
||||
}
|
||||
return new TimeSlotSnapshot(
|
||||
dateAssignmentRepository.findForScheduleRange(startDate, endDate),
|
||||
timeSlotScopeRepository.findByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_WEEKDAY),
|
||||
timeSlotRepository.findAllForSchedule()
|
||||
);
|
||||
}
|
||||
|
||||
private void primeConsumedHoursBeforeRange(Semester semester,
|
||||
List<ScheduleRule> rules,
|
||||
LocalDate rangeStart,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress) {
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||
return;
|
||||
@@ -175,7 +356,17 @@ public class ScheduleGeneratorService {
|
||||
List<ScheduleRule> sortedRules = sortRules(rules);
|
||||
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
|
||||
for (ScheduleRule rule : sortedRules) {
|
||||
processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
targetGroup,
|
||||
targetTeacherId,
|
||||
consumedHoursProgress,
|
||||
null,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,19 +376,21 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
|
||||
private void processRuleForDate(ScheduleRule rule,
|
||||
LocalDate date,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
List<RenderedLessonDto> result) {
|
||||
Semester semester,
|
||||
LocalDate date,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
List<RenderedLessonDto> result,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
if (rule.getLectureAcademicHours() == null
|
||||
|| rule.getLaboratoryAcademicHours() == null
|
||||
|| rule.getPracticeAcademicHours() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
|
||||
if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) {
|
||||
return;
|
||||
}
|
||||
if (!rule.getSubject().isActiveOn(date)) {
|
||||
@@ -207,8 +400,7 @@ public class ScheduleGeneratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot);
|
||||
if (eligibleGroups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +425,7 @@ public class ScheduleGeneratorService {
|
||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||
continue;
|
||||
}
|
||||
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||
if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||
continue;
|
||||
}
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||
@@ -283,7 +475,7 @@ public class ScheduleGeneratorService {
|
||||
.orElse(0);
|
||||
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||
totalHours, consumedHours, remainingAfterLesson));
|
||||
totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot));
|
||||
}
|
||||
for (ActiveLessonScope scope : activeScopes) {
|
||||
consumedHoursProgress.put(
|
||||
@@ -294,16 +486,20 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule,
|
||||
StudentGroup targetGroup,
|
||||
Semester semester,
|
||||
LocalDate date,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot) {
|
||||
if (targetGroup != null) {
|
||||
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
|
||||
return targetGroup.isActiveOn(date) && dateSnapshot.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))
|
||||
.filter(group -> dateSnapshot.isTheoryDay(group, semester, date))
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
}
|
||||
@@ -356,6 +552,12 @@ public class ScheduleGeneratorService {
|
||||
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
|
||||
}
|
||||
|
||||
private record GroupSemesterKey(Long groupId, Long semesterId) {
|
||||
}
|
||||
|
||||
private record ScopeOrderKey(Long scopeId, Integer orderNumber) {
|
||||
}
|
||||
|
||||
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||
ScheduleRuleSlot slot,
|
||||
LocalDate date,
|
||||
@@ -365,8 +567,9 @@ public class ScheduleGeneratorService {
|
||||
List<Subgroup> subgroups,
|
||||
int lessonTypeAcademicHours,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||
int remainingAfterLesson,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date);
|
||||
List<StudentGroup> sortedGroups = groups.stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
@@ -415,23 +618,55 @@ public class ScheduleGeneratorService {
|
||||
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||
}
|
||||
|
||||
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
||||
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
return effectiveScope(date)
|
||||
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
|
||||
scope.getId(),
|
||||
baseSlot.getOrderNumber()))
|
||||
.orElse(baseSlot);
|
||||
}
|
||||
private static final class TimeSlotSnapshot {
|
||||
|
||||
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()));
|
||||
private final Map<LocalDate, Long> dateScopeIds;
|
||||
private final Map<Integer, Long> weekdayScopeIds;
|
||||
private final Map<ScopeOrderKey, TimeSlot> slotsByScopeAndOrder;
|
||||
|
||||
private TimeSlotSnapshot(Collection<TimeSlotDateAssignment> dateAssignments,
|
||||
Collection<TimeSlotScope> weekdayScopes,
|
||||
Collection<TimeSlot> timeSlots) {
|
||||
this.dateScopeIds = dateAssignments.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
TimeSlotDateAssignment::getDate,
|
||||
assignment -> assignment.getTimeSlotScope().getId()
|
||||
));
|
||||
this.weekdayScopeIds = weekdayScopes.stream()
|
||||
.filter(scope -> scope.getDayOfWeek() != null)
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
TimeSlotScope::getDayOfWeek,
|
||||
TimeSlotScope::getId,
|
||||
(first, second) -> first
|
||||
));
|
||||
this.slotsByScopeAndOrder = timeSlots.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
slot -> new ScopeOrderKey(
|
||||
slot.getTimeSlotScope().getId(),
|
||||
slot.getOrderNumber()
|
||||
),
|
||||
slot -> slot
|
||||
));
|
||||
}
|
||||
|
||||
private static TimeSlotSnapshot empty() {
|
||||
return new TimeSlotSnapshot(List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private TimeSlot resolve(TimeSlot baseSlot, LocalDate date) {
|
||||
if (baseSlot == null || baseSlot.getOrderNumber() == null || date == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
Long scopeId = Optional.ofNullable(dateScopeIds.get(date))
|
||||
.orElseGet(() -> weekdayScopeIds.get(date.getDayOfWeek().getValue()));
|
||||
if (scopeId == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
return slotsByScopeAndOrder.getOrDefault(
|
||||
new ScopeOrderKey(scopeId, baseSlot.getOrderNumber()),
|
||||
baseSlot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static String dayName(DayOfWeek dayOfWeek) {
|
||||
|
||||
@@ -41,10 +41,10 @@ public class ScheduleQueryService {
|
||||
if (date == null) {
|
||||
throw new IllegalArgumentException("Дата расписания обязательна");
|
||||
}
|
||||
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
|
||||
List<StudentGroup> groups = groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
|
||||
.toList();
|
||||
List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
|
||||
return deduplicate(generatedLessons);
|
||||
}
|
||||
|
||||
@@ -85,16 +85,70 @@ public class ScheduleQueryService {
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return searchInternal(
|
||||
groupId,
|
||||
teacherId,
|
||||
classroomId,
|
||||
departmentId,
|
||||
subjectId,
|
||||
lessonTypeId,
|
||||
timeSlotId,
|
||||
parity,
|
||||
startDate,
|
||||
endDate,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп.
|
||||
* Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы
|
||||
* этот путь не превратился во второй общий поисковый API.
|
||||
*/
|
||||
public List<RenderedLessonDto> searchForAggregation(Long departmentId,
|
||||
Long timeSlotId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return searchInternal(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
departmentId,
|
||||
null,
|
||||
null,
|
||||
timeSlotId,
|
||||
null,
|
||||
startDate,
|
||||
endDate,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> searchInternal(Long groupId,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
Long departmentId,
|
||||
Long subjectId,
|
||||
Long lessonTypeId,
|
||||
Long timeSlotId,
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
boolean enforceInteractiveGroupLimit) {
|
||||
validateRange(startDate, endDate);
|
||||
List<RenderedLessonDto> generatedLessons;
|
||||
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
|
||||
if (teacherOnlySearch) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
|
||||
generatedLessons = groups.stream()
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
||||
.toList();
|
||||
List<StudentGroup> groups = resolveGroups(
|
||||
groupId,
|
||||
departmentId,
|
||||
startDate,
|
||||
endDate,
|
||||
enforceInteractiveGroupLimit
|
||||
);
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
|
||||
}
|
||||
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
|
||||
.findByLessonDateBetweenWithDetails(startDate, endDate);
|
||||
@@ -249,7 +303,11 @@ public class ScheduleQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
|
||||
private List<StudentGroup> resolveGroups(Long groupId,
|
||||
Long departmentId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
boolean enforceInteractiveGroupLimit) {
|
||||
if (groupId != null) {
|
||||
return groupRepository.findById(groupId)
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
@@ -265,7 +323,9 @@ public class ScheduleQueryService {
|
||||
groups = groups.stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.toList();
|
||||
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
if (enforceInteractiveGroupLimit
|
||||
&& departmentId == null
|
||||
&& groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class SubjectImportService {
|
||||
|
||||
private static final int MAX_NAME_LENGTH = 200;
|
||||
private static final int MAX_CODE_LENGTH = 20;
|
||||
|
||||
private final SubjectRepository subjectRepository;
|
||||
|
||||
public SubjectImportService(SubjectRepository subjectRepository) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<Subject> importSubjects(List<CreateSubjectRequest> requests, Long departmentId) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
throw new IllegalArgumentException("Передайте список дисциплин для загрузки");
|
||||
}
|
||||
if (departmentId == null || departmentId <= 0) {
|
||||
throw new IllegalArgumentException("Кафедра обязательна");
|
||||
}
|
||||
|
||||
List<SubjectCandidate> candidates = normalizeCandidates(requests);
|
||||
Map<String, Subject> existingByCanonicalName = new LinkedHashMap<>();
|
||||
for (SubjectCandidate candidate : candidates) {
|
||||
subjectRepository.findByNameIgnoreCase(candidate.name()).ifPresent(existing -> {
|
||||
if (!Objects.equals(existing.getDepartmentId(), departmentId)) {
|
||||
throw new ScheduleConflictException(
|
||||
"Дисциплина «" + candidate.name() + "» уже принадлежит другой кафедре"
|
||||
);
|
||||
}
|
||||
existingByCanonicalName.put(candidate.canonicalName(), existing);
|
||||
});
|
||||
}
|
||||
|
||||
List<Subject> subjects = new ArrayList<>(candidates.size());
|
||||
for (SubjectCandidate candidate : candidates) {
|
||||
Subject subject = existingByCanonicalName.getOrDefault(
|
||||
candidate.canonicalName(),
|
||||
new Subject()
|
||||
);
|
||||
subject.setName(candidate.name());
|
||||
subject.setCode(candidate.code());
|
||||
subject.setDepartmentId(departmentId);
|
||||
if (subject.isArchivedRecord()) {
|
||||
subject.restore();
|
||||
}
|
||||
subjects.add(subject);
|
||||
}
|
||||
|
||||
try {
|
||||
return subjectRepository.saveAllAndFlush(subjects);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Дисциплина с таким названием уже существует"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SubjectCandidate> normalizeCandidates(List<CreateSubjectRequest> requests) {
|
||||
Map<String, SubjectCandidate> unique = new LinkedHashMap<>();
|
||||
for (CreateSubjectRequest request : requests) {
|
||||
if (request == null || request.getName() == null || request.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String name = request.getName().trim();
|
||||
String code = trimToNull(request.getCode());
|
||||
if (name.length() > MAX_NAME_LENGTH) {
|
||||
throw new IllegalArgumentException("Название дисциплины не может быть длиннее 200 символов");
|
||||
}
|
||||
if (code != null && code.length() > MAX_CODE_LENGTH) {
|
||||
throw new IllegalArgumentException("Код дисциплины не может быть длиннее 20 символов");
|
||||
}
|
||||
String canonicalName = name.toLowerCase(Locale.ROOT);
|
||||
unique.put(canonicalName, new SubjectCandidate(canonicalName, name, code));
|
||||
}
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private record SubjectCandidate(String canonicalName, String name, String code) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.model.Department;
|
||||
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.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TeacherDepartmentService {
|
||||
|
||||
private final TeacherDepartmentAssignmentRepository assignmentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
this(assignmentRepository, userRepository, departmentRepository,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
BusinessTimeService businessTime) {
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<User> findTeachersForDepartmentAtDate(Long departmentId, LocalDate date) {
|
||||
if (departmentId == null || date == null) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
assignmentRepository.findDepartmentTeachersAtDate(departmentId, date).stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(teacher -> teacher.getRole() == Role.TEACHER)
|
||||
.filter(teacher -> teacher.isActiveOn(date))
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasAssignmentAtDate(Long teacherId, Long departmentId, LocalDate date) {
|
||||
return teacherId != null
|
||||
&& departmentId != null
|
||||
&& date != null
|
||||
&& assignmentRepository.existsActiveAssignment(teacherId, departmentId, date);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Long> findPrimaryDepartmentIdAtDate(Long teacherId, LocalDate date) {
|
||||
if (teacherId == null || date == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return assignmentRepository.findPrimaryAtDate(teacherId, date)
|
||||
.map(TeacherDepartmentAssignment::getDepartment)
|
||||
.filter(Objects::nonNull)
|
||||
.map(Department::getId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, List<TeacherDepartmentAssignment>> findAssignmentsForTeachersBetween(
|
||||
Collection<Long> teacherIds,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
if (teacherIds == null || teacherIds.isEmpty() || startDate == null || endDate == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Set<Long> normalizedIds = teacherIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return assignmentRepository.findForTeachersBetween(normalizedIds, startDate, endDate).stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
assignment -> assignment.getTeacher().getId(),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
}
|
||||
|
||||
public Optional<Long> resolveDepartmentIdAtDate(
|
||||
Collection<TeacherDepartmentAssignment> assignments,
|
||||
LocalDate date
|
||||
) {
|
||||
if (assignments == null || date == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return assignments.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(assignment -> isActiveAt(assignment, date))
|
||||
.filter(assignment -> assignment.getDepartment() != null)
|
||||
.sorted(Comparator
|
||||
.comparing(
|
||||
(TeacherDepartmentAssignment assignment) ->
|
||||
!Boolean.TRUE.equals(assignment.getPrimaryAssignment())
|
||||
)
|
||||
.thenComparing(
|
||||
TeacherDepartmentAssignment::getValidFrom,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
)
|
||||
.thenComparing(
|
||||
TeacherDepartmentAssignment::getId,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
))
|
||||
.map(assignment -> assignment.getDepartment().getId())
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
|
||||
DepartmentTransferRequest request,
|
||||
LocalDate today,
|
||||
Long createdBy) {
|
||||
if (request == null || request.departmentId() == null) {
|
||||
throw new IllegalArgumentException("Кафедра обязательна");
|
||||
}
|
||||
LocalDate operationDate = today == null ? businessTime.today() : today;
|
||||
LocalDate validFrom = request.validFrom() == null ? operationDate : request.validFrom();
|
||||
User teacher = userRepository.findByIdForUpdate(teacherId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
if (teacher.getRole() != Role.TEACHER) {
|
||||
throw new IllegalArgumentException("Преподаватель не найден");
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивного преподавателя нельзя перевести");
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId())
|
||||
.filter(candidate -> !candidate.isArchivedRecord())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Активная кафедра не найдена"));
|
||||
|
||||
List<TeacherDepartmentAssignment> primaryHistory = new ArrayList<>(
|
||||
assignmentRepository.findPrimaryHistoryForUpdate(teacherId)
|
||||
);
|
||||
TeacherDepartmentAssignment sameDate = primaryHistory.stream()
|
||||
.filter(assignment -> validFrom.equals(assignment.getValidFrom()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (sameDate != null) {
|
||||
sameDate.setDepartment(department);
|
||||
sameDate.setComment(normalizeComment(request.comment()));
|
||||
sameDate.setCreatedBy(createdBy);
|
||||
TeacherDepartmentAssignment saved = saveAssignment(sameDate);
|
||||
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
|
||||
return saved;
|
||||
}
|
||||
|
||||
primaryHistory.stream()
|
||||
.filter(assignment -> isActiveAt(assignment, validFrom))
|
||||
.findFirst()
|
||||
.ifPresent(current -> current.setValidTo(validFrom.minusDays(1)));
|
||||
|
||||
LocalDate nextValidFrom = primaryHistory.stream()
|
||||
.map(TeacherDepartmentAssignment::getValidFrom)
|
||||
.filter(date -> date != null && date.isAfter(validFrom))
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(null);
|
||||
|
||||
assignmentRepository.flush();
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setValidTo(nextValidFrom == null ? null : nextValidFrom.minusDays(1));
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(normalizeComment(request.comment()));
|
||||
assignment.setCreatedBy(createdBy);
|
||||
TeacherDepartmentAssignment saved = saveAssignment(assignment);
|
||||
|
||||
primaryHistory.add(saved);
|
||||
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void synchronizeLegacyDepartment(User teacher,
|
||||
List<TeacherDepartmentAssignment> primaryHistory,
|
||||
LocalDate date) {
|
||||
resolveDepartmentIdAtDate(primaryHistory, date).ifPresent(departmentId -> {
|
||||
if (!Objects.equals(teacher.getDepartmentId(), departmentId)) {
|
||||
teacher.setDepartmentId(departmentId);
|
||||
userRepository.save(teacher);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignment saveAssignment(TeacherDepartmentAssignment assignment) {
|
||||
try {
|
||||
return assignmentRepository.saveAndFlush(assignment);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Период основной кафедры пересекается с другим назначением преподавателя"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isActiveAt(TeacherDepartmentAssignment assignment, LocalDate date) {
|
||||
return assignment.getValidFrom() != null
|
||||
&& !assignment.getValidFrom().isAfter(date)
|
||||
&& (assignment.getValidTo() == null || !assignment.getValidTo().isBefore(date));
|
||||
}
|
||||
|
||||
private String normalizeComment(String comment) {
|
||||
return comment == null || comment.isBlank() ? null : comment.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class TimeSlotService {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final String SLOT_CONFLICT_MESSAGE =
|
||||
"Номер пары или интервал времени уже занят в выбранной сетке";
|
||||
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
|
||||
public TimeSlotService(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TimeSlot create(TimeSlotDto request) {
|
||||
validate(request);
|
||||
TimeSlotScope targetScope = lockScope(request.scopeId());
|
||||
ensureUniqueAndNonOverlapping(request, null);
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, targetScope, request);
|
||||
return save(timeSlot);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TimeSlot update(Long id, TimeSlotDto request) {
|
||||
validate(request);
|
||||
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
|
||||
|
||||
Long currentScopeId = timeSlot.getTimeSlotScope().getId();
|
||||
List<Long> scopeIds = Stream.of(currentScopeId, request.scopeId())
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
TimeSlotScope targetScope = null;
|
||||
for (Long scopeId : scopeIds) {
|
||||
TimeSlotScope lockedScope = lockScope(scopeId);
|
||||
if (scopeId.equals(request.scopeId())) {
|
||||
targetScope = lockedScope;
|
||||
}
|
||||
}
|
||||
if (targetScope == null) {
|
||||
throw new NoSuchElementException("Сетка времени не найдена");
|
||||
}
|
||||
|
||||
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)
|
||||
&& !APPLY_MODE_DEFAULT.equals(targetScope.getApplyMode())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку"
|
||||
);
|
||||
}
|
||||
|
||||
ensureUniqueAndNonOverlapping(request, id);
|
||||
apply(timeSlot, targetScope, request);
|
||||
return save(timeSlot);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
|
||||
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот, который используется в правилах расписания"
|
||||
);
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.delete(timeSlot);
|
||||
timeSlotRepository.flush();
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот, который используется в расписании",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(TimeSlotDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные временного слота обязательны");
|
||||
}
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
throw new IllegalArgumentException("Номер пары должен быть больше нуля");
|
||||
}
|
||||
if (request.scopeId() == null) {
|
||||
throw new IllegalArgumentException("Выберите сетку времени");
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
throw new IllegalArgumentException("Время начала и окончания обязательно");
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
throw new IllegalArgumentException("Время начала должно быть раньше времени окончания");
|
||||
}
|
||||
if (Duration.between(request.startTime(), request.endTime()).toMinutes() < 1) {
|
||||
throw new IllegalArgumentException("Длительность временного слота должна быть не меньше одной минуты");
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSlotScope lockScope(Long scopeId) {
|
||||
return timeSlotScopeRepository.findByIdForUpdate(scopeId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Сетка времени не найдена"));
|
||||
}
|
||||
|
||||
private void ensureUniqueAndNonOverlapping(TimeSlotDto request, Long currentId) {
|
||||
boolean duplicateOrder = currentId == null
|
||||
? timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumber(
|
||||
request.scopeId(), request.orderNumber())
|
||||
: timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumberAndIdNot(
|
||||
request.scopeId(), request.orderNumber(), currentId);
|
||||
boolean overlapping = currentId == null
|
||||
? timeSlotRepository.existsOverlapping(
|
||||
request.scopeId(), request.startTime(), request.endTime())
|
||||
: timeSlotRepository.existsOverlappingExcluding(
|
||||
request.scopeId(), currentId, request.startTime(), request.endTime());
|
||||
if (duplicateOrder || overlapping) {
|
||||
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotScope scope, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(scope);
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
timeSlot.setDurationMinutes((int) Duration.between(request.startTime(), request.endTime()).toMinutes());
|
||||
}
|
||||
|
||||
private TimeSlot save(TimeSlot timeSlot) {
|
||||
try {
|
||||
return timeSlotRepository.saveAndFlush(timeSlot);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@Service
|
||||
public class CourseAndSemesterCalculator {
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy) {
|
||||
return getActualCourse(yearStartStudy, LocalDate.now());
|
||||
return getActualCourse(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
|
||||
}
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
|
||||
@@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator {
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy) {
|
||||
return getActualSemester(yearStartStudy, LocalDate.now());
|
||||
return getActualSemester(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
|
||||
|
||||
@@ -10,6 +10,11 @@ spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
spring.jpa.hibernate.ddl-auto=none
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.open-in-view=false
|
||||
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
|
||||
spring.jackson.time-zone=UTC
|
||||
|
||||
# Единая зона календарных бизнес-дат. Абсолютные timestamps хранятся в UTC.
|
||||
app.time.business-zone=${BUSINESS_TIME_ZONE:Europe/Moscow}
|
||||
|
||||
# Мультитенантность
|
||||
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
||||
@@ -33,5 +38,24 @@ app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m}
|
||||
app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
|
||||
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
|
||||
app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false}
|
||||
app.jwt.refresh-cleanup.enabled=${JWT_REFRESH_CLEANUP_ENABLED:true}
|
||||
app.jwt.refresh-cleanup.retention=${JWT_REFRESH_CLEANUP_RETENTION:30d}
|
||||
app.jwt.refresh-cleanup.batch-size=${JWT_REFRESH_CLEANUP_BATCH_SIZE:500}
|
||||
app.jwt.refresh-cleanup.max-batches-per-tenant=${JWT_REFRESH_CLEANUP_MAX_BATCHES:20}
|
||||
app.jwt.refresh-cleanup.interval-ms=${JWT_REFRESH_CLEANUP_INTERVAL_MS:3600000}
|
||||
app.jwt.refresh-cleanup.initial-delay-ms=${JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS:60000}
|
||||
|
||||
# Защита входа. X-Forwarded-For принимается только от перечисленных proxy-сетей.
|
||||
app.login-security.max-failures=${LOGIN_RATE_MAX_FAILURES:5}
|
||||
app.login-security.attempt-window=${LOGIN_RATE_ATTEMPT_WINDOW:15m}
|
||||
app.login-security.base-block-duration=${LOGIN_RATE_BASE_BLOCK_DURATION:1m}
|
||||
app.login-security.max-block-duration=${LOGIN_RATE_MAX_BLOCK_DURATION:15m}
|
||||
app.login-security.audit-cleanup.enabled=${LOGIN_AUDIT_CLEANUP_ENABLED:true}
|
||||
app.login-security.audit-cleanup.retention=${LOGIN_AUDIT_RETENTION:90d}
|
||||
app.login-security.audit-cleanup.batch-size=${LOGIN_AUDIT_CLEANUP_BATCH_SIZE:500}
|
||||
app.login-security.audit-cleanup.max-batches-per-tenant=${LOGIN_AUDIT_CLEANUP_MAX_BATCHES:20}
|
||||
app.login-security.audit-cleanup.interval-ms=${LOGIN_AUDIT_CLEANUP_INTERVAL_MS:86400000}
|
||||
app.login-security.audit-cleanup.initial-delay-ms=${LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS:120000}
|
||||
app.http.trusted-proxy-cidrs=${TRUSTED_PROXY_CIDRS:}
|
||||
|
||||
#logging.level.root=DEBUG
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE subgroups
|
||||
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
|
||||
ON subgroups (group_id, lower(name))
|
||||
WHERE status <> 'ARCHIVED';
|
||||
@@ -1,94 +0,0 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
cancel_violations BIGINT;
|
||||
move_violations BIGINT;
|
||||
replace_violations BIGINT;
|
||||
format_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO cancel_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'CANCEL'
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL);
|
||||
|
||||
SELECT count(*)
|
||||
INTO move_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'MOVE'
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO replace_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'REPLACE'
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO format_violations
|
||||
FROM schedule_overrides
|
||||
WHERE new_lesson_format IS NOT NULL
|
||||
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
|
||||
|
||||
IF cancel_violations > 0
|
||||
OR move_violations > 0
|
||||
OR replace_violations > 0
|
||||
OR format_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инварианты точечных изменений: CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
format_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
ADD CONSTRAINT chk_schedule_overrides_cancel_payload
|
||||
CHECK (
|
||||
action <> 'CANCEL'
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_lesson_format IS NULL
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_move_payload
|
||||
CHECK (
|
||||
action <> 'MOVE'
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_replace_payload
|
||||
CHECK (
|
||||
action <> 'REPLACE'
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_format
|
||||
CHECK (
|
||||
new_lesson_format IS NULL
|
||||
OR new_lesson_format IN ('Очно', 'Онлайн')
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_move_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_format;
|
||||
@@ -1,97 +0,0 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
lecture_violations BIGINT;
|
||||
laboratory_violations BIGINT;
|
||||
practice_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO lecture_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(lecture_academic_hours, 2) <> 0;
|
||||
|
||||
SELECT count(*)
|
||||
INTO laboratory_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(laboratory_academic_hours, 2) <> 0;
|
||||
|
||||
SELECT count(*)
|
||||
INTO practice_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(practice_academic_hours, 2) <> 0;
|
||||
|
||||
IF lecture_violations > 0
|
||||
OR laboratory_violations > 0
|
||||
OR practice_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инвариант чётности академических часов правил расписания: лекции=%s, лабораторные=%s, практики=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
lecture_violations,
|
||||
laboratory_violations,
|
||||
practice_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
duplicate_slot_groups BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO duplicate_slot_groups
|
||||
FROM (
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
FROM schedule_rule_slots
|
||||
GROUP BY
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
HAVING count(*) > 1
|
||||
) duplicates;
|
||||
|
||||
IF duplicate_slot_groups > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23505',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инвариант уникальности слотов правил расписания: найдено групп точных дублей=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
duplicate_slot_groups
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ADD CONSTRAINT chk_schedule_rules_academic_hours_even
|
||||
CHECK (
|
||||
mod(lecture_academic_hours, 2) = 0
|
||||
AND mod(laboratory_academic_hours, 2) = 0
|
||||
AND mod(practice_academic_hours, 2) = 0
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even;
|
||||
|
||||
ALTER TABLE schedule_rule_slots
|
||||
ADD CONSTRAINT uq_schedule_rule_slots_exact_payload
|
||||
UNIQUE (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ClientIpResolverTest {
|
||||
|
||||
@Test
|
||||
void ignoresForwardedHeaderFromUntrustedClient() {
|
||||
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
|
||||
MockHttpServletRequest request = request("203.0.113.7", "192.0.2.10");
|
||||
|
||||
assertThat(resolver.resolve(request)).isEqualTo("203.0.113.7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsFirstUntrustedAddressFromRightBehindTrustedProxyChain() {
|
||||
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16, 172.16.0.0/12");
|
||||
MockHttpServletRequest request = request(
|
||||
"10.42.1.15",
|
||||
"192.0.2.99, 198.51.100.23, 172.18.0.4"
|
||||
);
|
||||
|
||||
assertThat(resolver.resolve(request)).isEqualTo("198.51.100.23");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEntireForwardedHeaderWhenItContainsInvalidAddress() {
|
||||
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
|
||||
MockHttpServletRequest request = request("10.42.1.15", "198.51.100.23, attacker.example");
|
||||
|
||||
assertThat(resolver.resolve(request)).isEqualTo("10.42.1.15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidTrustedProxyConfigurationAtStartup() {
|
||||
assertThatThrownBy(() -> new ClientIpResolver("10.42.0.0/99"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Некорректная маска");
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String remoteAddress, String forwardedFor) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRemoteAddr(remoteAddress);
|
||||
request.addHeader("X-Forwarded-For", forwardedFor);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = LoginRateLimitConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = "management.endpoint.health.validate-group-membership=false"
|
||||
)
|
||||
class LoginRateLimitConcurrencyIntegrationTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private AuthLoginRateLimitRepository rateLimitRepository;
|
||||
|
||||
@Autowired
|
||||
private AuthLoginAttemptAuditRepository auditRepository;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private ExecutorService executor;
|
||||
|
||||
@BeforeEach
|
||||
void clearLoginSecurityTables() {
|
||||
auditRepository.deleteAll();
|
||||
rateLimitRepository.deleteAll();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopExecutor() throws InterruptedException {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoServiceInstancesShareAtomicPostgreSqlCounter() throws Exception {
|
||||
LoginSecurityProperties properties = new LoginSecurityProperties();
|
||||
properties.setMaxFailures(2);
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
|
||||
LoginRateLimitService firstPod = service(properties, encoder);
|
||||
LoginRateLimitService secondPod = service(properties, encoder);
|
||||
String username = "missing-" + UUID.randomUUID();
|
||||
String clientIp = "198.51.100.42";
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<LoginRateLimitService.AuthenticationDecision>> futures = List.of(
|
||||
executor.submit(() -> authenticateAfterSignal(firstPod, username, clientIp, ready, start)),
|
||||
executor.submit(() -> authenticateAfterSignal(secondPod, username, clientIp, ready, start))
|
||||
);
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
|
||||
List<LoginRateLimitService.AuthenticationDecision.Status> statuses = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS).status(),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS).status()
|
||||
);
|
||||
assertThat(statuses).containsExactlyInAnyOrder(
|
||||
LoginRateLimitService.AuthenticationDecision.Status.REJECTED,
|
||||
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED
|
||||
);
|
||||
|
||||
Integer failureCount = jdbcTemplate.queryForObject("""
|
||||
SELECT failure_count
|
||||
FROM auth_login_rate_limits
|
||||
WHERE tenant = ? AND username_normalized = ? AND client_ip = ?
|
||||
""", Integer.class, "tenant", username, clientIp);
|
||||
assertThat(failureCount).isEqualTo(2);
|
||||
assertThat(auditRepository.count()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeletesOnlyExpiredAuditAndInactiveCounters() {
|
||||
Instant now = NOW;
|
||||
Instant cutoff = now.minus(90, java.time.temporal.ChronoUnit.DAYS);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO auth_login_attempt_audit
|
||||
(tenant, username_normalized, client_ip, outcome, occurred_at)
|
||||
VALUES
|
||||
('tenant', 'old', '198.51.100.1', 'FAILURE', ?),
|
||||
('tenant', 'fresh', '198.51.100.2', 'FAILURE', ?)
|
||||
""", timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO auth_login_rate_limits
|
||||
(tenant, username_normalized, client_ip, failure_count,
|
||||
window_started_at, blocked_until, updated_at)
|
||||
VALUES
|
||||
('tenant', 'old', '198.51.100.1', 1, ?, NULL, ?),
|
||||
('tenant', 'blocked', '198.51.100.2', 5, ?, ?, ?),
|
||||
('tenant', 'fresh', '198.51.100.3', 1, ?, NULL, ?)
|
||||
""",
|
||||
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.plusSeconds(300)),
|
||||
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)),
|
||||
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
|
||||
|
||||
assertThat(auditRepository.deleteCleanupBatch(cutoff, 100)).isEqualTo(1);
|
||||
assertThat(rateLimitRepository.deleteStaleBatch(cutoff, now, 100)).isEqualTo(1);
|
||||
assertThat(auditRepository.count()).isEqualTo(1);
|
||||
assertThat(rateLimitRepository.count()).isEqualTo(2);
|
||||
}
|
||||
|
||||
private Timestamp timestamp(Instant value) {
|
||||
return Timestamp.from(value);
|
||||
}
|
||||
|
||||
private LoginRateLimitService.AuthenticationDecision authenticateAfterSignal(
|
||||
LoginRateLimitService service,
|
||||
String username,
|
||||
String clientIp,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start
|
||||
) throws InterruptedException {
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Не удалось синхронно запустить попытки входа");
|
||||
}
|
||||
return transactionTemplate.execute(status ->
|
||||
service.authenticate("tenant", username, "неверный-пароль", clientIp)
|
||||
);
|
||||
}
|
||||
|
||||
private LoginRateLimitService service(LoginSecurityProperties properties,
|
||||
BCryptPasswordEncoder encoder) {
|
||||
return new LoginRateLimitService(
|
||||
rateLimitRepository,
|
||||
auditRepository,
|
||||
userRepository,
|
||||
encoder,
|
||||
properties,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = AuthLoginRateLimit.class)
|
||||
@EnableJpaRepositories(basePackageClasses = AuthLoginRateLimitRepository.class)
|
||||
static class TestApplication {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LoginRateLimitServiceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
|
||||
|
||||
private AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private AuthLoginAttemptAuditRepository auditRepository;
|
||||
private UserRepository userRepository;
|
||||
private BCryptPasswordEncoder passwordEncoder;
|
||||
private LoginSecurityProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
rateLimitRepository = mock(AuthLoginRateLimitRepository.class);
|
||||
auditRepository = mock(AuthLoginAttemptAuditRepository.class);
|
||||
userRepository = mock(UserRepository.class);
|
||||
passwordEncoder = new BCryptPasswordEncoder(4);
|
||||
properties = new LoginSecurityProperties();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fifthFailureBlocksKeyAndWritesAuditWithoutPassword() {
|
||||
AuthLoginRateLimit state = state(4, NOW, null);
|
||||
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
|
||||
.thenReturn(Optional.of(state));
|
||||
when(userRepository.findByUsername("Admin")).thenReturn(Optional.empty());
|
||||
LoginRateLimitService service = service();
|
||||
|
||||
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
|
||||
"tenant", "Admin", "секретный-пароль", "198.51.100.5"
|
||||
);
|
||||
|
||||
assertThat(decision.status())
|
||||
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
|
||||
assertThat(decision.retryAfterSeconds()).isEqualTo(60);
|
||||
assertThat(state.getFailureCount()).isEqualTo(5);
|
||||
assertThat(state.getBlockedUntil()).isEqualTo(NOW.plusSeconds(60));
|
||||
|
||||
ArgumentCaptor<AuthLoginAttemptAudit> auditCaptor =
|
||||
ArgumentCaptor.forClass(AuthLoginAttemptAudit.class);
|
||||
verify(auditRepository).save(auditCaptor.capture());
|
||||
assertThat(auditCaptor.getValue().getOutcome()).isEqualTo("BLOCKED");
|
||||
assertThat(auditCaptor.getValue().getRetryAfterSeconds()).isEqualTo(60);
|
||||
assertThat(auditCaptor.getValue().getUsernameNormalized()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeBlockDoesNotQueryUserAndReturnsRemainingDelay() {
|
||||
AuthLoginRateLimit state = state(5, NOW, NOW.plusSeconds(37));
|
||||
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
|
||||
.thenReturn(Optional.of(state));
|
||||
LoginRateLimitService service = service();
|
||||
|
||||
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
|
||||
"tenant", "admin", "любой", "198.51.100.5"
|
||||
);
|
||||
|
||||
assertThat(decision.status())
|
||||
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
|
||||
assertThat(decision.retryAfterSeconds()).isEqualTo(37);
|
||||
verify(userRepository, never()).findByUsername(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedUserIsRejectedLikeUnknownUser() {
|
||||
AuthLoginRateLimit state = state(0, NOW, null);
|
||||
when(rateLimitRepository.findForUpdate("tenant", "archived", "198.51.100.5"))
|
||||
.thenReturn(Optional.of(state));
|
||||
User user = new User();
|
||||
user.setPassword(passwordEncoder.encode("верный-пароль"));
|
||||
user.archive("Тест");
|
||||
when(userRepository.findByUsername("archived")).thenReturn(Optional.of(user));
|
||||
LoginRateLimitService service = service();
|
||||
|
||||
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
|
||||
"tenant", "archived", "верный-пароль", "198.51.100.5"
|
||||
);
|
||||
|
||||
assertThat(decision.status())
|
||||
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.REJECTED);
|
||||
assertThat(state.getFailureCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulLoginRemovesPreviousFailureState() {
|
||||
AuthLoginRateLimit state = state(2, NOW, null);
|
||||
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
|
||||
.thenReturn(Optional.of(state));
|
||||
User user = new User();
|
||||
user.setPassword(passwordEncoder.encode("верный-пароль"));
|
||||
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
|
||||
LoginRateLimitService service = service();
|
||||
|
||||
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
|
||||
"tenant", "admin", "верный-пароль", "198.51.100.5"
|
||||
);
|
||||
|
||||
assertThat(decision.status())
|
||||
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.AUTHENTICATED);
|
||||
assertThat(decision.user()).isSameAs(user);
|
||||
verify(rateLimitRepository).delete(state);
|
||||
verify(auditRepository, never()).save(any());
|
||||
}
|
||||
|
||||
private LoginRateLimitService service() {
|
||||
return new LoginRateLimitService(
|
||||
rateLimitRepository,
|
||||
auditRepository,
|
||||
userRepository,
|
||||
passwordEncoder,
|
||||
properties,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
}
|
||||
|
||||
private AuthLoginRateLimit state(int failureCount,
|
||||
Instant windowStartedAt,
|
||||
Instant blockedUntil) {
|
||||
AuthLoginRateLimit state = new AuthLoginRateLimit();
|
||||
state.setFailureCount(failureCount);
|
||||
state.setWindowStartedAt(windowStartedAt);
|
||||
state.setBlockedUntil(blockedUntil);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("Tenant-aware очистка refresh-сессий")
|
||||
class RefreshTokenCleanupJobTest {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Moscow");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-18T09:00:00Z"), ZONE);
|
||||
private static final Instant NOW = CLOCK.instant();
|
||||
private static final Duration RETENTION = Duration.ofDays(30);
|
||||
|
||||
private ExecutorService executor;
|
||||
|
||||
@AfterEach
|
||||
void cleanup() throws InterruptedException {
|
||||
TenantContext.clear();
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("два pod удаляют старые строки идемпотентно и сохраняют active/fresh audit")
|
||||
void twoPodsKeepActiveAndFreshAuditRows() throws Exception {
|
||||
TenantRoutingDataSource routingDataSource = routing("alpha", "beta");
|
||||
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||
TokenStore store = new TokenStore("alpha", "beta");
|
||||
when(repository.deleteCleanupBatch(any(Instant.class), anyInt()))
|
||||
.thenAnswer(invocation -> store.deleteBatch(
|
||||
TenantContext.getCurrentTenant(),
|
||||
invocation.getArgument(0),
|
||||
invocation.getArgument(1)
|
||||
));
|
||||
RefreshTokenCleanupJob firstPod = job(routingDataSource, repository, true);
|
||||
RefreshTokenCleanupJob secondPod = job(routingDataSource, repository, true);
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Future<RefreshTokenCleanupJob.CleanupSummary> first = executor.submit(() -> {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
return firstPod.cleanupNow();
|
||||
});
|
||||
Future<RefreshTokenCleanupJob.CleanupSummary> second = executor.submit(() -> {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
return secondPod.cleanupNow();
|
||||
});
|
||||
start.countDown();
|
||||
|
||||
var firstSummary = first.get(5, TimeUnit.SECONDS);
|
||||
var secondSummary = second.get(5, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(firstSummary.deletedCount() + secondSummary.deletedCount()).isEqualTo(4);
|
||||
assertThat(firstSummary.failedTenantCount() + secondSummary.failedTenantCount()).isZero();
|
||||
assertThat(store.labels("alpha")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
|
||||
assertThat(store.labels("beta")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
|
||||
|
||||
var repeated = firstPod.cleanupNow();
|
||||
assertThat(repeated.deletedCount()).isZero();
|
||||
assertThat(store.labels("alpha")).hasSize(3);
|
||||
assertThat(store.labels("beta")).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("повторный локальный запуск пропускается, пока текущий проход не завершён")
|
||||
void overlappingRunInsidePodIsSkipped() throws Exception {
|
||||
TenantRoutingDataSource routingDataSource = routing("alpha");
|
||||
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||
CountDownLatch enteredRepository = new CountDownLatch(1);
|
||||
CountDownLatch releaseRepository = new CountDownLatch(1);
|
||||
when(repository.deleteCleanupBatch(any(Instant.class), anyInt())).thenAnswer(invocation -> {
|
||||
enteredRepository.countDown();
|
||||
if (!releaseRepository.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Тест не разрешил завершить очистку");
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
RefreshTokenCleanupJob job = job(routingDataSource, repository, true);
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
Future<RefreshTokenCleanupJob.CleanupSummary> running = executor.submit(job::cleanupNow);
|
||||
assertThat(enteredRepository.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
var overlapping = job.cleanupNow();
|
||||
|
||||
assertThat(overlapping.skippedBecauseAlreadyRunning()).isTrue();
|
||||
releaseRepository.countDown();
|
||||
assertThat(running.get(5, TimeUnit.SECONDS).skippedBecauseAlreadyRunning()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("отключённое расписание не обращается к tenant-БД")
|
||||
void disabledScheduleDoesNothing() {
|
||||
TenantRoutingDataSource routingDataSource = routing("alpha");
|
||||
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||
RefreshTokenCleanupJob job = job(routingDataSource, repository, false);
|
||||
|
||||
job.cleanupScheduled();
|
||||
|
||||
verifyNoInteractions(repository);
|
||||
}
|
||||
|
||||
private RefreshTokenCleanupJob job(TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
boolean enabled) {
|
||||
return new RefreshTokenCleanupJob(
|
||||
routingDataSource,
|
||||
repository,
|
||||
enabled,
|
||||
RETENTION,
|
||||
2,
|
||||
10,
|
||||
CLOCK
|
||||
);
|
||||
}
|
||||
|
||||
private TenantRoutingDataSource routing(String... tenants) {
|
||||
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
||||
Map<String, TenantConfig> configs = new LinkedHashMap<>();
|
||||
for (String tenant : tenants) {
|
||||
configs.put(tenant, new TenantConfig(tenant, tenant, "jdbc:test:" + tenant, "user", "password"));
|
||||
}
|
||||
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.copyOf(configs));
|
||||
return routingDataSource;
|
||||
}
|
||||
|
||||
private static final class TokenStore {
|
||||
|
||||
private final Map<String, List<TokenRow>> rowsByTenant = new LinkedHashMap<>();
|
||||
|
||||
private TokenStore(String... tenants) {
|
||||
for (String tenant : tenants) {
|
||||
rowsByTenant.put(tenant, new ArrayList<>(List.of(
|
||||
new TokenRow("old-expired", minusDays(NOW, 31), null),
|
||||
new TokenRow("old-revoked", plusDays(NOW, 7), minusDays(NOW, 31)),
|
||||
new TokenRow("fresh-expired", minusDays(NOW, 1), null),
|
||||
new TokenRow("fresh-revoked", plusDays(NOW, 7), minusDays(NOW, 1)),
|
||||
new TokenRow("active", plusDays(NOW, 7), null)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized int deleteBatch(String tenant, Instant cutoff, int batchSize) {
|
||||
assertThat(tenant).isNotBlank();
|
||||
List<TokenRow> rows = rowsByTenant.get(tenant);
|
||||
List<TokenRow> candidates = rows.stream()
|
||||
.filter(row -> row.expiresAt().isBefore(cutoff)
|
||||
|| row.revokedAt() != null && row.revokedAt().isBefore(cutoff))
|
||||
.limit(batchSize)
|
||||
.toList();
|
||||
rows.removeAll(candidates);
|
||||
return candidates.size();
|
||||
}
|
||||
|
||||
private synchronized List<String> labels(String tenant) {
|
||||
return rowsByTenant.get(tenant).stream().map(TokenRow::label).toList();
|
||||
}
|
||||
}
|
||||
|
||||
private static Instant minusDays(Instant value, long days) {
|
||||
return value.minus(days, java.time.temporal.ChronoUnit.DAYS);
|
||||
}
|
||||
|
||||
private static Instant plusDays(Instant value, long days) {
|
||||
return value.plus(days, java.time.temporal.ChronoUnit.DAYS);
|
||||
}
|
||||
|
||||
private record TokenRow(String label, Instant expiresAt, Instant revokedAt) {
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -131,6 +133,82 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
|
||||
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCleanupKeepsActiveAndFreshAuditRows() throws Exception {
|
||||
User user = createTestUser();
|
||||
Instant now = Instant.now();
|
||||
Instant cutoff = now.minus(30, ChronoUnit.DAYS);
|
||||
AuthRefreshToken oldExpired = token(user, "old-expired", now.minus(31, ChronoUnit.DAYS), null);
|
||||
AuthRefreshToken oldRevoked = token(user, "old-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(31, ChronoUnit.DAYS));
|
||||
AuthRefreshToken freshExpired = token(user, "fresh-expired", now.minus(1, ChronoUnit.DAYS), null);
|
||||
AuthRefreshToken freshRevoked = token(user, "fresh-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(1, ChronoUnit.DAYS));
|
||||
AuthRefreshToken active = token(user, "active", now.plus(7, ChronoUnit.DAYS), null);
|
||||
tokenRepository.saveAllAndFlush(List.of(
|
||||
oldExpired,
|
||||
oldRevoked,
|
||||
freshExpired,
|
||||
freshRevoked,
|
||||
active
|
||||
));
|
||||
List<String> relevantHashes = List.of(
|
||||
oldExpired.getTokenHash(),
|
||||
oldRevoked.getTokenHash(),
|
||||
freshExpired.getTokenHash(),
|
||||
freshRevoked.getTokenHash(),
|
||||
active.getTokenHash()
|
||||
);
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<Integer>> futures = List.of(
|
||||
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start)),
|
||||
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start))
|
||||
);
|
||||
awaitReady(ready);
|
||||
start.countDown();
|
||||
|
||||
int deleted = futures.get(0).get(10, TimeUnit.SECONDS)
|
||||
+ futures.get(1).get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(deleted).isEqualTo(2);
|
||||
List<AuthRefreshToken> remaining = tokenRepository.findAll().stream()
|
||||
.filter(token -> relevantHashes.contains(token.getTokenHash()))
|
||||
.toList();
|
||||
assertThat(remaining)
|
||||
.extracting(AuthRefreshToken::getTokenHash)
|
||||
.containsExactlyInAnyOrder(
|
||||
freshExpired.getTokenHash(),
|
||||
freshRevoked.getTokenHash(),
|
||||
active.getTokenHash()
|
||||
);
|
||||
assertThat(tokenRepository.deleteCleanupBatch(cutoff, 100)).isZero();
|
||||
}
|
||||
|
||||
private int cleanupAfterSignal(Instant cutoff,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) throws InterruptedException {
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Не удалось синхронно запустить очистку refresh-сессий");
|
||||
}
|
||||
return tokenRepository.deleteCleanupBatch(cutoff, 100);
|
||||
}
|
||||
|
||||
private AuthRefreshToken token(User user,
|
||||
String marker,
|
||||
Instant expiresAt,
|
||||
Instant revokedAt) {
|
||||
AuthRefreshToken token = new AuthRefreshToken();
|
||||
token.setUser(user);
|
||||
token.setTenant(TENANT);
|
||||
token.setTokenHash(service.hashToken(marker + "-" + UUID.randomUUID()));
|
||||
token.setIssuedAt(Instant.now().minus(40, ChronoUnit.DAYS));
|
||||
token.setExpiresAt(expiresAt);
|
||||
token.setRevokedAt(revokedAt);
|
||||
return token;
|
||||
}
|
||||
|
||||
private Optional<RefreshTokenRotation> rotateAfterSignal(
|
||||
String rawToken,
|
||||
CountDownLatch ready,
|
||||
|
||||
@@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -35,7 +36,7 @@ class RefreshTokenServiceTest {
|
||||
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
|
||||
assertThat(stored.getTokenHash()).hasSize(64);
|
||||
assertThat(stored.getTenant()).isEqualTo("magistr");
|
||||
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
|
||||
assertThat(stored.getExpiresAt()).isAfter(Instant.now());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,8 +97,8 @@ class RefreshTokenServiceTest {
|
||||
token.setUser(user());
|
||||
token.setTenant("magistr");
|
||||
token.setTokenHash(hash);
|
||||
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
|
||||
token.setExpiresAt(LocalDateTime.now().plusDays(1));
|
||||
token.setIssuedAt(Instant.now().minus(1, ChronoUnit.MINUTES));
|
||||
token.setExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,30 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.config.auth.ClientIpResolver;
|
||||
import com.magistr.app.config.auth.LoginRateLimitService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AuthControllerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearAuthContext() {
|
||||
AuthContext.clear();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -35,4 +45,35 @@ class AuthControllerTest {
|
||||
assertThat(body).containsKey("departmentId");
|
||||
assertThat(body.get("departmentId")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturnsTooManyRequestsAndRetryAfterWhenKeyIsBlocked() {
|
||||
LoginRateLimitService rateLimitService = mock(LoginRateLimitService.class);
|
||||
ClientIpResolver clientIpResolver = mock(ClientIpResolver.class);
|
||||
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
|
||||
LoginRequest loginRequest = new LoginRequest();
|
||||
loginRequest.setUsername("admin");
|
||||
loginRequest.setPassword("неверный-пароль");
|
||||
TenantContext.setCurrentTenant("tenant");
|
||||
when(clientIpResolver.resolve(servletRequest)).thenReturn("198.51.100.7");
|
||||
when(rateLimitService.authenticate(
|
||||
"tenant", "admin", "неверный-пароль", "198.51.100.7"
|
||||
)).thenReturn(new LoginRateLimitService.AuthenticationDecision(
|
||||
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED,
|
||||
null,
|
||||
73
|
||||
));
|
||||
AuthController controller = new AuthController(
|
||||
rateLimitService, clientIpResolver, null, null, null
|
||||
);
|
||||
|
||||
ResponseEntity<LoginResponse> response = controller.login(loginRequest, servletRequest);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(429);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("73");
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().isSuccess()).isFalse();
|
||||
assertThat(response.getBody().getMessage())
|
||||
.isEqualTo("Слишком много попыток входа. Повторите позже");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.SubjectImportService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -51,7 +53,9 @@ class DepartmentWorkspaceControllerTest {
|
||||
mock(DepartmentRepository.class),
|
||||
mock(TeacherDepartmentAssignmentRepository.class),
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
mock(ScheduleQueryService.class),
|
||||
mock(TeacherDepartmentService.class),
|
||||
mock(SubjectImportService.class)
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||
|
||||
@@ -61,14 +65,13 @@ class DepartmentWorkspaceControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
|
||||
void teachersUseDatedAssignmentsWithoutLegacyFallback() {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
|
||||
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
|
||||
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
|
||||
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
|
||||
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
|
||||
when(teacherDepartmentService.findTeachersForDepartmentAtDate(1L, LocalDate.now()))
|
||||
.thenReturn(List.of(directTeacher, assignedTeacher));
|
||||
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||
mock(SubjectRepository.class),
|
||||
@@ -77,13 +80,15 @@ class DepartmentWorkspaceControllerTest {
|
||||
mock(DepartmentRepository.class),
|
||||
assignmentRepository,
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
mock(ScheduleQueryService.class),
|
||||
teacherDepartmentService,
|
||||
mock(SubjectImportService.class)
|
||||
);
|
||||
|
||||
List<UserResponse> teachers = controller.getTeachers(1L);
|
||||
|
||||
assertThat(teachers).extracting(UserResponse::getFullName)
|
||||
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
|
||||
.containsExactlyInAnyOrder("Петров Препод Петрович", "Препод Тест Тестович");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -103,7 +108,9 @@ class DepartmentWorkspaceControllerTest {
|
||||
departmentRepository,
|
||||
assignmentRepository,
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
mock(ScheduleQueryService.class),
|
||||
mock(TeacherDepartmentService.class),
|
||||
mock(SubjectImportService.class)
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||
import com.magistr.app.config.auth.JwtTokenService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class EducationOfficeRoleMatrixTest {
|
||||
|
||||
private static final String TENANT = "tenant-role-matrix";
|
||||
private static final String TOKEN = "education-office-token";
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TenantContext.clear();
|
||||
TenantContext.setCurrentTenant(TENANT);
|
||||
|
||||
JwtTokenService jwtTokenService = mock(JwtTokenService.class);
|
||||
when(jwtTokenService.authenticate(TOKEN, TENANT)).thenReturn(Optional.of(
|
||||
new AuthenticatedUser(10L, "учебный.отдел", Role.EDUCATION_OFFICE, null)
|
||||
));
|
||||
|
||||
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
|
||||
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
|
||||
when(specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
|
||||
when(specialtiesRepository.existsById(1L)).thenReturn(true);
|
||||
when(profileRepository.findBySpecialityIdOrderByNameAsc(1L)).thenReturn(List.of());
|
||||
SpecialityController specialityController = new SpecialityController(
|
||||
specialtiesRepository,
|
||||
profileRepository
|
||||
);
|
||||
|
||||
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||
when(educationFormRepository.findAllByOrderByNameAsc()).thenReturn(List.of());
|
||||
when(educationFormRepository.findByName("Очная")).thenReturn(Optional.empty());
|
||||
when(educationFormRepository.save(any(EducationForm.class))).thenAnswer(invocation -> {
|
||||
EducationForm form = invocation.getArgument(0);
|
||||
form.setId(20L);
|
||||
return form;
|
||||
});
|
||||
when(educationFormRepository.existsById(20L)).thenReturn(true);
|
||||
when(groupRepository.findByEducationFormId(20L)).thenReturn(List.of());
|
||||
when(calendarRepository.countByStudyFormId(20L)).thenReturn(0L);
|
||||
EducationFormController educationFormController = new EducationFormController(
|
||||
educationFormRepository,
|
||||
groupRepository,
|
||||
calendarRepository
|
||||
);
|
||||
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(specialityController, educationFormController)
|
||||
.addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void educationOfficeInitializesCalendarAndManagesEducationFormsWithoutForbidden() throws Exception {
|
||||
mockMvc.perform(get("/api/specialties").header("Authorization", bearer()))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(get("/api/specialties/1/profiles").header("Authorization", bearer()))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(get("/api/education-forms").header("Authorization", bearer()))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/education-forms")
|
||||
.header("Authorization", bearer())
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"Очная\"}"))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(delete("/api/education-forms/20").header("Authorization", bearer()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void educationOfficeStillCannotModifySpecialties() throws Exception {
|
||||
mockMvc.perform(post("/api/specialties")
|
||||
.header("Authorization", bearer())
|
||||
.contentType("application/json")
|
||||
.content("{\"specialityName\":\"Тест\",\"specialityCode\":\"01\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private String bearer() {
|
||||
return "Bearer " + TOKEN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Безопасное преобразование нарушений целостности БД")
|
||||
class GlobalExceptionHandlerDataIntegrityTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
@DisplayName("известный CHECK возвращает русский 400 без JDBC details")
|
||||
void knownCheckConstraintReturnsSafeBadRequest() {
|
||||
String technicalMessage = "ERROR: new row violates check constraint "
|
||||
+ "\"chk_time_slots_time_range\" Detail: failing row contains secret-value";
|
||||
|
||||
var response = handler.handleDataIntegrityViolation(
|
||||
violation(technicalMessage, "23514"),
|
||||
request("POST", "/api/admin/time-slots")
|
||||
);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Некорректный запрос")
|
||||
.containsEntry("message", "Время начала пары должно быть раньше времени окончания");
|
||||
assertNoTechnicalDetails(response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("известный UNIQUE возвращает русский 409 без имени ограничения")
|
||||
void knownUniqueConstraintReturnsSafeConflict() {
|
||||
String technicalMessage = "duplicate key value violates unique constraint "
|
||||
+ "\"uq_subjects_name_ci\" Detail: Key (lower(name))=(secret-value) already exists";
|
||||
|
||||
var response = handler.handleDataIntegrityViolation(
|
||||
violation(technicalMessage, "23505"),
|
||||
request("POST", "/api/subjects")
|
||||
);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(409);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Конфликт")
|
||||
.containsEntry("message", "Дисциплина с таким названием уже существует");
|
||||
assertNoTechnicalDetails(response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("неизвестный constraint получает общий 409 без сырого исключения")
|
||||
void unknownConstraintReturnsGenericSafeConflict() {
|
||||
String technicalMessage = "duplicate key violates constraint \"tenant_private_constraint\" "
|
||||
+ "jdbc:postgresql://db/private?password=secret-value";
|
||||
|
||||
var response = handler.handleDataIntegrityViolation(
|
||||
violation(technicalMessage, "23505"),
|
||||
request("PUT", "/api/private")
|
||||
);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(409);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Конфликт")
|
||||
.containsEntry("message", "Такая запись уже существует");
|
||||
assertNoTechnicalDetails(response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("неизвестная integrity-ошибка не превращается в 500 и не раскрывает cause")
|
||||
void unknownIntegrityFailureReturnsGenericConflict() {
|
||||
var exception = new DataIntegrityViolationException(
|
||||
"could not execute statement; jdbc:postgresql://db/private",
|
||||
new RuntimeException("password=secret-value")
|
||||
);
|
||||
|
||||
var response = handler.handleDataIntegrityViolation(
|
||||
exception,
|
||||
request("DELETE", "/api/private/1")
|
||||
);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(409);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("message", "Операция нарушает ограничения целостности данных");
|
||||
assertNoTechnicalDetails(response.getBody());
|
||||
}
|
||||
|
||||
private DataIntegrityViolationException violation(String message, String sqlState) {
|
||||
return new DataIntegrityViolationException(message, new SQLException(message, sqlState));
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String method, String path) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(method, path);
|
||||
request.setRequestURI(path);
|
||||
return request;
|
||||
}
|
||||
|
||||
private void assertNoTechnicalDetails(Map<String, Object> body) {
|
||||
assertThat(body).isNotNull();
|
||||
String rendered = body.toString().toLowerCase();
|
||||
assertThat(rendered)
|
||||
.doesNotContain("constraint")
|
||||
.doesNotContain("jdbc:")
|
||||
.doesNotContain("password")
|
||||
.doesNotContain("secret-value")
|
||||
.doesNotContain("sqlstate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.TeacherSubject;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TeacherSubjectControllerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearAuthContext() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentCanManageTeacherThroughAdditionalActiveAssignment() {
|
||||
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
User teacher = teacher();
|
||||
Subject subject = new Subject();
|
||||
subject.setId(20L);
|
||||
subject.setName("Высшая математика");
|
||||
subject.setDepartmentId(1L);
|
||||
when(userRepository.findById(teacher.getId())).thenReturn(Optional.of(teacher));
|
||||
when(subjectRepository.findById(subject.getId())).thenReturn(Optional.of(subject));
|
||||
when(teacherDepartmentService.hasAssignmentAtDate(
|
||||
teacher.getId(),
|
||||
1L,
|
||||
LocalDate.now()
|
||||
)).thenReturn(true);
|
||||
TeacherSubjectController controller = new TeacherSubjectController(
|
||||
teacherSubjectRepository,
|
||||
userRepository,
|
||||
subjectRepository,
|
||||
teacherDepartmentService
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 1L));
|
||||
|
||||
var response = controller.create(Map.of("userId", teacher.getId(), "subjectId", subject.getId()));
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
ArgumentCaptor<TeacherSubject> captor = ArgumentCaptor.forClass(TeacherSubject.class);
|
||||
verify(teacherSubjectRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getUserId()).isEqualTo(teacher.getId());
|
||||
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
|
||||
}
|
||||
|
||||
private User teacher() {
|
||||
User teacher = new User();
|
||||
teacher.setId(10L);
|
||||
teacher.setUsername("teacher");
|
||||
teacher.setRole(Role.TEACHER);
|
||||
teacher.setFullName("Петров Пётр Петрович");
|
||||
teacher.setJobTitle("Доцент");
|
||||
teacher.setDepartmentId(2L);
|
||||
return teacher;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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 com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,16 +25,19 @@ class UserControllerTest {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
UserController controller = new UserController(
|
||||
userRepository,
|
||||
null,
|
||||
departmentRepository,
|
||||
assignmentRepository
|
||||
assignmentRepository,
|
||||
teacherDepartmentService
|
||||
);
|
||||
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
||||
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(List.of());
|
||||
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
|
||||
when(teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||
org.mockito.ArgumentMatchers.eq(2L),
|
||||
org.mockito.ArgumentMatchers.any()
|
||||
))
|
||||
.thenReturn(List.of(teacher));
|
||||
when(departmentRepository.findById(2L))
|
||||
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||
|
||||
@@ -1,24 +1,87 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.WorkloadSummaryDto;
|
||||
import com.magistr.app.model.Classroom;
|
||||
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.ClassroomRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WorkloadControllerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearAuthContext() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherWorkloadUsesDepartmentAtEachLessonDate() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||
LocalDate startDate = LocalDate.of(2026, 1, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 3, 31);
|
||||
RenderedLessonDto oldLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 1, 15), 1L);
|
||||
RenderedLessonDto newLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 3, 15), 2L);
|
||||
List<TeacherDepartmentAssignment> assignments = List.of(
|
||||
assignment(10L, 1L, LocalDate.of(2025, 9, 1), LocalDate.of(2026, 2, 28)),
|
||||
assignment(10L, 2L, LocalDate.of(2026, 3, 1), null)
|
||||
);
|
||||
when(scheduleQueryService.searchForAggregation(any(), any(), eq(startDate), eq(endDate)))
|
||||
.thenReturn(List.of(oldLesson, newLesson));
|
||||
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
|
||||
eq(java.util.Set.of(10L)),
|
||||
eq(startDate),
|
||||
eq(endDate)
|
||||
)).thenReturn(Map.of(10L, assignments));
|
||||
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, oldLesson.date()))
|
||||
.thenReturn(Optional.of(1L));
|
||||
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, newLesson.date()))
|
||||
.thenReturn(Optional.of(2L));
|
||||
when(departmentRepository.findById(1L))
|
||||
.thenReturn(Optional.of(new Department(1L, "Кафедра ИБ", 1L)));
|
||||
when(departmentRepository.findById(2L))
|
||||
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
mock(ClassroomRepository.class),
|
||||
departmentRepository,
|
||||
teacherDepartmentService
|
||||
);
|
||||
authenticate(Role.ADMIN, null);
|
||||
|
||||
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).extracting(WorkloadSummaryDto::departmentId)
|
||||
.containsExactly(1L, 2L);
|
||||
assertThat(result).extracting(WorkloadSummaryDto::lessonCount)
|
||||
.containsOnly(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
@@ -26,19 +89,97 @@ class WorkloadControllerTest {
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
classroomRepository,
|
||||
mock(UserRepository.class),
|
||||
mock(DepartmentRepository.class)
|
||||
mock(DepartmentRepository.class),
|
||||
mock(TeacherDepartmentService.class)
|
||||
);
|
||||
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
|
||||
when(scheduleQueryService.searchForAggregation(any(), eq(1L), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
|
||||
authenticate(Role.SCHEDULE_VIEWER, null);
|
||||
|
||||
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
|
||||
|
||||
assertThat(result).extracting("id").containsExactly(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentRoleUsesOwnDepartmentForEveryWorkloadEndpoint() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
|
||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
classroomRepository,
|
||||
departmentRepository,
|
||||
teacherDepartmentService
|
||||
);
|
||||
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 5, 31);
|
||||
LocalDate date = LocalDate.of(2026, 5, 15);
|
||||
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
when(departmentRepository.findAll()).thenReturn(List.of());
|
||||
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
|
||||
authenticate(Role.DEPARTMENT, 7L);
|
||||
|
||||
controller.teacherWorkload(startDate, endDate, null);
|
||||
controller.classroomWorkload(startDate, endDate, 7L);
|
||||
controller.departmentWorkload(startDate, endDate, null);
|
||||
controller.timeSlotWorkload(startDate, endDate, 7L);
|
||||
controller.freeClassrooms(date, 3L);
|
||||
|
||||
verify(scheduleQueryService, times(4))
|
||||
.searchForAggregation(eq(7L), isNull(), eq(startDate), eq(endDate));
|
||||
verify(scheduleQueryService)
|
||||
.searchForAggregation(eq(7L), eq(3L), eq(date), eq(date));
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentRoleCannotRequestAnotherDepartment() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
mock(ClassroomRepository.class),
|
||||
mock(DepartmentRepository.class),
|
||||
mock(TeacherDepartmentService.class)
|
||||
);
|
||||
authenticate(Role.DEPARTMENT, 7L);
|
||||
|
||||
assertThatThrownBy(() -> controller.teacherWorkload(
|
||||
LocalDate.of(2026, 5, 1),
|
||||
LocalDate.of(2026, 5, 31),
|
||||
8L
|
||||
))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class, exception -> {
|
||||
assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(exception.getReason()).isEqualTo("Нельзя просматривать нагрузку другой кафедры");
|
||||
});
|
||||
verifyNoInteractions(scheduleQueryService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void privilegedRoleKeepsGlobalScope() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
mock(ClassroomRepository.class),
|
||||
mock(DepartmentRepository.class),
|
||||
mock(TeacherDepartmentService.class)
|
||||
);
|
||||
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 5, 31);
|
||||
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
authenticate(Role.EDUCATION_OFFICE, null);
|
||||
|
||||
controller.timeSlotWorkload(startDate, endDate, null);
|
||||
|
||||
verify(scheduleQueryService)
|
||||
.searchForAggregation(isNull(), isNull(), eq(startDate), eq(endDate));
|
||||
}
|
||||
|
||||
private Classroom classroom(Long id, Boolean available) {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(id);
|
||||
@@ -47,4 +188,36 @@ class WorkloadControllerTest {
|
||||
classroom.setIsAvailable(available);
|
||||
return classroom;
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(Long teacherId,
|
||||
String teacherName,
|
||||
LocalDate date,
|
||||
Long timeSlotId) {
|
||||
RenderedLessonDto lesson = mock(RenderedLessonDto.class);
|
||||
when(lesson.teacherId()).thenReturn(teacherId);
|
||||
when(lesson.teacherName()).thenReturn(teacherName);
|
||||
when(lesson.date()).thenReturn(date);
|
||||
when(lesson.timeSlotId()).thenReturn(timeSlotId);
|
||||
return lesson;
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignment assignment(Long teacherId,
|
||||
Long departmentId,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo) {
|
||||
User teacher = new User();
|
||||
teacher.setId(teacherId);
|
||||
Department department = new Department(departmentId, "Кафедра " + departmentId, departmentId);
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setValidTo(validTo);
|
||||
assignment.setPrimaryAssignment(true);
|
||||
return assignment;
|
||||
}
|
||||
|
||||
private void authenticate(Role role, Long departmentId) {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(100L, "test", role, departmentId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Инварианты учебных периодов базовой схемы")
|
||||
class AcademicPeriodInvariantMigrationIntegrationTest {
|
||||
|
||||
private static final String YEAR_OVERLAP_CONSTRAINT = "ex_academic_years_no_overlap";
|
||||
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
|
||||
void migrationEnforcesAcademicPeriodInvariants() throws SQLException {
|
||||
Flyway flyway = flyway("1");
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current()).isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long academicYearId = insertAcademicYear(
|
||||
connection,
|
||||
"2026-2027",
|
||||
LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2027, 6, 30)
|
||||
);
|
||||
long autumnId = insertSemester(
|
||||
connection,
|
||||
academicYearId,
|
||||
"autumn",
|
||||
LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2027, 1, 31)
|
||||
);
|
||||
|
||||
assertSqlState(
|
||||
() -> insertAcademicYear(
|
||||
connection,
|
||||
"2027-overlap",
|
||||
LocalDate.of(2027, 6, 30),
|
||||
LocalDate.of(2028, 6, 30)
|
||||
),
|
||||
"23P01",
|
||||
YEAR_OVERLAP_CONSTRAINT
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertSemester(
|
||||
connection,
|
||||
academicYearId,
|
||||
"spring",
|
||||
LocalDate.of(2026, 6, 30),
|
||||
LocalDate.of(2027, 6, 30)
|
||||
),
|
||||
"23514",
|
||||
"Семестр должен полностью находиться в границах учебного года"
|
||||
);
|
||||
|
||||
long springId = insertSemester(
|
||||
connection,
|
||||
academicYearId,
|
||||
"spring",
|
||||
LocalDate.of(2027, 2, 1),
|
||||
LocalDate.of(2027, 6, 30)
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateDate(
|
||||
connection,
|
||||
"UPDATE semesters SET start_date = ? WHERE id = ?",
|
||||
LocalDate.of(2027, 1, 31),
|
||||
springId
|
||||
),
|
||||
"23P01",
|
||||
SEMESTER_OVERLAP_CONSTRAINT
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateDate(
|
||||
connection,
|
||||
"UPDATE academic_years SET start_date = ? WHERE id = ?",
|
||||
LocalDate.of(2026, 7, 2),
|
||||
academicYearId
|
||||
),
|
||||
"23514",
|
||||
"Новые границы учебного года не включают все его семестры"
|
||||
);
|
||||
|
||||
assertThat(autumnId).isPositive();
|
||||
assertThat(constraintCount(connection, "academic_years", YEAR_OVERLAP_CONSTRAINT)).isOne();
|
||||
assertThat(constraintCount(connection, "semesters", SEMESTER_OVERLAP_CONSTRAINT)).isOne();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся учебных годов")
|
||||
void migrationProtectsConcurrentAcademicYearWrites() throws Exception {
|
||||
Flyway flyway = flyway("1");
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<InsertOutcome>> futures = List.of(
|
||||
executor.submit(() -> concurrentYearInsert(
|
||||
"2090-2091-a",
|
||||
LocalDate.of(2090, 9, 1),
|
||||
LocalDate.of(2091, 6, 30),
|
||||
ready,
|
||||
start
|
||||
)),
|
||||
executor.submit(() -> concurrentYearInsert(
|
||||
"2091-2092-b",
|
||||
LocalDate.of(2091, 6, 30),
|
||||
LocalDate.of(2092, 6, 30),
|
||||
ready,
|
||||
start
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
List<InsertOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
|
||||
if ("23P01".equals(outcome.sqlState())) {
|
||||
assertThat(outcome.message()).contains(YEAR_OVERLAP_CONSTRAINT);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private InsertOutcome concurrentYearInsert(String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
connection.setAutoCommit(false);
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
|
||||
}
|
||||
try {
|
||||
insertAcademicYear(connection, title, startDate, endDate);
|
||||
connection.commit();
|
||||
return new InsertOutcome(true, null, null);
|
||||
} catch (SQLException exception) {
|
||||
connection.rollback();
|
||||
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return new InsertOutcome(false, null, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway flyway(String targetVersion) {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion(targetVersion))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private long insertAcademicYear(Connection connection,
|
||||
String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_years (title, start_date, end_date)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setString(1, title);
|
||||
statement.setDate(2, Date.valueOf(startDate));
|
||||
statement.setDate(3, Date.valueOf(endDate));
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long insertSemester(Connection connection,
|
||||
long academicYearId,
|
||||
String semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO semesters (
|
||||
academic_year_id,
|
||||
semester_type,
|
||||
start_date,
|
||||
end_date
|
||||
) VALUES (?, ?, ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setLong(1, academicYearId);
|
||||
statement.setString(2, semesterType);
|
||||
statement.setDate(3, Date.valueOf(startDate));
|
||||
statement.setDate(4, Date.valueOf(endDate));
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDate(Connection connection, String sql, LocalDate value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setDate(1, Date.valueOf(value));
|
||||
statement.setLong(2, id);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long constraintCount(Connection connection,
|
||||
String tableName,
|
||||
String constraintName) throws SQLException {
|
||||
return queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM pg_constraint WHERE conrelid = (?::TEXT)::regclass AND conname = ?",
|
||||
tableName,
|
||||
constraintName
|
||||
);
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSqlState(ThrowingSqlRunnable operation,
|
||||
String expectedSqlState,
|
||||
String expectedMessagePart) {
|
||||
Throwable failure = catchThrowable(operation::run);
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
|
||||
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingSqlRunnable {
|
||||
void run() throws SQLException;
|
||||
}
|
||||
|
||||
private record InsertOutcome(boolean success, String sqlState, String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Инварианты зависимостей календарных графиков базовой схемы")
|
||||
class AcademicStructureInvariantIntegrationTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы")
|
||||
void baselineProtectsCalendarDependencies() throws SQLException {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current()).isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long groupId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"
|
||||
);
|
||||
long assignmentId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM student_group_calendar_assignments WHERE group_id = ?",
|
||||
groupId
|
||||
);
|
||||
long calendarId = queryLong(
|
||||
connection,
|
||||
"SELECT calendar_id FROM student_group_calendar_assignments WHERE id = ?",
|
||||
assignmentId
|
||||
);
|
||||
long otherFormId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
|
||||
);
|
||||
|
||||
assertThat(incompatibleAssignmentCount(connection)).isZero();
|
||||
assertSqlState(
|
||||
() -> updateLong(
|
||||
connection,
|
||||
"UPDATE academic_calendars SET study_form_id = ? WHERE id = ?",
|
||||
otherFormId,
|
||||
calendarId
|
||||
),
|
||||
"23514",
|
||||
"сохранённые назначения групп станут несовместимыми"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateLong(
|
||||
connection,
|
||||
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
|
||||
otherFormId,
|
||||
groupId
|
||||
),
|
||||
"23514",
|
||||
"сохранённые назначения календарных графиков станут несовместимыми"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateInt(
|
||||
connection,
|
||||
"UPDATE academic_calendars SET course_count = ? WHERE id = ?",
|
||||
3,
|
||||
calendarId
|
||||
),
|
||||
"23514",
|
||||
"сохранённая сетка выходит за новые измерения"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertOutOfRangeCalendarDay(connection, calendarId),
|
||||
"23514",
|
||||
"Строка сетки выходит за измерения календарного графика"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertOutOfRangeCalendarSubject(connection, calendarId),
|
||||
"23514",
|
||||
"Номер семестра дисциплины выходит за измерения календарного графика"
|
||||
);
|
||||
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM student_group_calendar_assignments WHERE id = ?",
|
||||
assignmentId
|
||||
)).isOne();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT course_count FROM academic_calendars WHERE id = ?",
|
||||
calendarId
|
||||
)).isEqualTo(4);
|
||||
assertThat(incompatibleAssignmentCount(connection)).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 не допускает гонку между назначением графика и изменением параметров группы")
|
||||
void baselineProtectsConcurrentGroupAndAssignmentWrites() throws Exception {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
long groupId;
|
||||
long calendarId;
|
||||
long academicYearId;
|
||||
long otherFormId;
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
groupId = insertCompatibleGroup(connection);
|
||||
calendarId = queryLong(connection, """
|
||||
SELECT calendar.id
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
|
||||
JOIN specialties specialty ON specialty.id = calendar.specialty_id
|
||||
JOIN education_forms study_form ON study_form.id = calendar.study_form_id
|
||||
WHERE academic_year.title = '2025-2026'
|
||||
AND specialty.specialty_code = '09.03.01'
|
||||
AND study_form.name = 'Бакалавриат'
|
||||
ORDER BY calendar.id
|
||||
LIMIT 1
|
||||
""");
|
||||
academicYearId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM academic_years WHERE title = '2025-2026'"
|
||||
);
|
||||
otherFormId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
|
||||
);
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
long finalGroupId = groupId;
|
||||
long finalCalendarId = calendarId;
|
||||
long finalAcademicYearId = academicYearId;
|
||||
long finalOtherFormId = otherFormId;
|
||||
List<Future<WriteOutcome>> futures = List.of(
|
||||
executor.submit(() -> concurrentGroupUpdate(
|
||||
finalGroupId,
|
||||
finalOtherFormId,
|
||||
ready,
|
||||
start
|
||||
)),
|
||||
executor.submit(() -> concurrentAssignmentInsert(
|
||||
finalGroupId,
|
||||
finalAcademicYearId,
|
||||
finalCalendarId,
|
||||
ready,
|
||||
start
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
List<WriteOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
assertThat(outcomes).filteredOn(WriteOutcome::success).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.sqlState()).isEqualTo("23514");
|
||||
assertThat(outcome.message()).containsAnyOf(
|
||||
"Календарный график не соответствует",
|
||||
"сохранённые назначения календарных графиков станут несовместимыми"
|
||||
);
|
||||
});
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(incompatibleAssignmentCount(connection)).isZero();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM student_group_calendar_assignments WHERE group_id = ?",
|
||||
groupId
|
||||
)).isLessThanOrEqualTo(1);
|
||||
}
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private WriteOutcome concurrentGroupUpdate(long groupId,
|
||||
long educationFormId,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
return concurrentWrite(
|
||||
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
|
||||
List.of(educationFormId, groupId),
|
||||
ready,
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
private WriteOutcome concurrentAssignmentInsert(long groupId,
|
||||
long academicYearId,
|
||||
long calendarId,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
return concurrentWrite(
|
||||
"""
|
||||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
List.of(groupId, academicYearId, calendarId),
|
||||
ready,
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
private WriteOutcome concurrentWrite(String sql,
|
||||
List<Long> parameters,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
connection.setAutoCommit(false);
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new WriteOutcome(false, null, "Не получен общий сигнал запуска");
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.size(); index++) {
|
||||
statement.setLong(index + 1, parameters.get(index));
|
||||
}
|
||||
statement.executeUpdate();
|
||||
connection.commit();
|
||||
return new WriteOutcome(true, null, null);
|
||||
} catch (SQLException exception) {
|
||||
connection.rollback();
|
||||
return new WriteOutcome(false, exception.getSQLState(), exception.getMessage());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return new WriteOutcome(false, null, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway baselineFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private long insertCompatibleGroup(Connection connection) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO student_groups (
|
||||
name,
|
||||
group_size,
|
||||
education_form_id,
|
||||
department_id,
|
||||
specialty_id,
|
||||
specialty_profile_id,
|
||||
year_start_study
|
||||
)
|
||||
SELECT
|
||||
'КОНКУРЕНТНАЯ-ГРУППА',
|
||||
20,
|
||||
study_form.id,
|
||||
department.id,
|
||||
specialty.id,
|
||||
profile.id,
|
||||
2025
|
||||
FROM education_forms study_form
|
||||
CROSS JOIN departments department
|
||||
CROSS JOIN specialties specialty
|
||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
|
||||
WHERE study_form.name = 'Бакалавриат'
|
||||
AND department.code = 1
|
||||
AND specialty.specialty_code = '09.03.01'
|
||||
AND profile.name = 'Без профиля'
|
||||
RETURNING id
|
||||
""")) {
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_calendar_days (
|
||||
calendar_id,
|
||||
course_number,
|
||||
date,
|
||||
week_number,
|
||||
day_of_week,
|
||||
activity_type_id
|
||||
) VALUES (?, 5, DATE '2025-09-01', 1, 1, (
|
||||
SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'
|
||||
))
|
||||
""")) {
|
||||
statement.setLong(1, calendarId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)
|
||||
VALUES (?, 9, (SELECT min(id) FROM subjects))
|
||||
""")) {
|
||||
statement.setLong(1, calendarId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long incompatibleAssignmentCount(Connection connection) throws SQLException {
|
||||
return queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM student_group_calendar_assignments assignment
|
||||
JOIN student_groups student_group ON student_group.id = assignment.group_id
|
||||
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
|
||||
WHERE assignment.academic_year_id <> calendar.academic_year_id
|
||||
OR student_group.specialty_id <> calendar.specialty_id
|
||||
OR student_group.specialty_profile_id <> calendar.specialty_profile_id
|
||||
OR student_group.education_form_id <> calendar.study_form_id
|
||||
""");
|
||||
}
|
||||
|
||||
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, value);
|
||||
statement.setLong(2, id);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateInt(Connection connection, String sql, int value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, value);
|
||||
statement.setLong(2, id);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSqlState(ThrowingSqlRunnable operation,
|
||||
String expectedSqlState,
|
||||
String expectedMessagePart) {
|
||||
Throwable failure = catchThrowable(operation::run);
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
|
||||
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingSqlRunnable {
|
||||
void run() throws SQLException;
|
||||
}
|
||||
|
||||
private record WriteOutcome(boolean success, String sqlState, String message) {
|
||||
}
|
||||
}
|
||||
@@ -14,31 +14,21 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Миграция инвариантов точечных изменений расписания")
|
||||
@DisplayName("Инварианты точечных изменений расписания базовой схемы")
|
||||
class ScheduleOverrideMigrationIntegrationTest {
|
||||
|
||||
private static final String[] OVERRIDE_CONSTRAINTS = {
|
||||
"chk_schedule_overrides_cancel_payload",
|
||||
"chk_schedule_overrides_move_payload",
|
||||
"chk_schedule_overrides_replace_payload",
|
||||
"chk_schedule_overrides_format"
|
||||
};
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
|
||||
Flyway flyway = targetThreeFlyway();
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
@@ -46,8 +36,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V3")
|
||||
.isEqualTo("3");
|
||||
.as("Последней успешно применённой миграцией должна быть V1")
|
||||
.isEqualTo("1");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
@@ -106,81 +96,11 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V3 останавливается на legacy-данных до добавления ограничений")
|
||||
void migrationFailsPreflightWithoutPartialConstraints() throws SQLException {
|
||||
Flyway targetTwo = targetTwoFlyway();
|
||||
targetTwo.clean();
|
||||
targetTwo.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
LocalDate.of(2026, 5, 4),
|
||||
"REPLACE",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetThree::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V3 должна остановить миграцию при пустом legacy-изменении REPLACE")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему на русском языке")
|
||||
.contains("Невозможно применить инварианты точечных изменений")
|
||||
.contains("REPLACE=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetThree.info().current())
|
||||
.as("После отката V3 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetThree.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна остаться V2")
|
||||
.isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '3'"
|
||||
))
|
||||
.as("V3 не должна отмечаться как успешно применённая")
|
||||
.isZero();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'schedule_overrides'::regclass
|
||||
AND conname::text = ANY (?)
|
||||
""",
|
||||
connection.createArrayOf("text", OVERRIDE_CONSTRAINTS)
|
||||
))
|
||||
.as("Ни одно ограничение V3 не должно остаться после неуспешного preflight")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway targetThreeFlyway() {
|
||||
private Flyway baselineFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("3"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private Flyway targetTwoFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
@@ -276,19 +196,6 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private String collectMessages(Throwable failure) {
|
||||
StringBuilder messages = new StringBuilder();
|
||||
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Throwable current = failure;
|
||||
while (current != null && visited.add(current)) {
|
||||
if (current.getMessage() != null) {
|
||||
messages.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,12 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Миграция чётности академических часов правил расписания")
|
||||
@DisplayName("Инварианты правил расписания базовой схемы")
|
||||
class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
|
||||
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
|
||||
@@ -30,9 +27,9 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||
void migrationEnforcesEvenAcademicHours() throws SQLException {
|
||||
Flyway flyway = targetFourFlyway();
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
@@ -40,8 +37,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V4")
|
||||
.isEqualTo("4");
|
||||
.as("Последней успешно применённой миграцией должна быть V1")
|
||||
.isEqualTo("1");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
@@ -63,7 +60,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
""",
|
||||
HOURS_CONSTRAINT
|
||||
))
|
||||
.as("Ограничение чётности V4 должно быть провалидировано")
|
||||
.as("Ограничение чётности V1 должно быть провалидировано")
|
||||
.isOne();
|
||||
|
||||
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
|
||||
@@ -73,113 +70,11 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения")
|
||||
void migrationFailsPreflightWithoutPartialConstraint() throws SQLException {
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
targetThree.clean();
|
||||
targetThree.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
insertScheduleRule(connection, seedIds, 1, 3, 5);
|
||||
}
|
||||
|
||||
Flyway targetFour = targetFourFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetFour::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V4 должна остановить миграцию при нечётных часах legacy-правила")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему на русском языке")
|
||||
.contains("Невозможно применить инвариант чётности академических часов правил расписания")
|
||||
.contains("лекции=1")
|
||||
.contains("лабораторные=1")
|
||||
.contains("практики=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetFour.info().current())
|
||||
.as("После отката V4 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetFour.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна остаться V3")
|
||||
.isEqualTo("3");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
|
||||
))
|
||||
.as("V4 не должна отмечаться как успешно применённая")
|
||||
.isZero();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'schedule_rules'::regclass
|
||||
AND conname = ?
|
||||
""",
|
||||
HOURS_CONSTRAINT
|
||||
))
|
||||
.as("Ограничение V4 не должно частично остаться после неуспешного preflight")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений")
|
||||
void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException {
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
targetThree.clean();
|
||||
targetThree.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
duplicateExistingSlot(connection);
|
||||
}
|
||||
|
||||
Flyway targetFour = targetFourFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetFour::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V4 должна остановить миграцию при точном legacy-дубле слота")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему уникальности на русском языке")
|
||||
.contains("Невозможно применить инвариант уникальности слотов правил расписания")
|
||||
.contains("групп точных дублей=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetFour.info().current())
|
||||
.as("После отката V4 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
|
||||
)).isZero();
|
||||
assertConstraintAbsent(connection, HOURS_CONSTRAINT);
|
||||
assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT);
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway targetFourFlyway() {
|
||||
private Flyway baselineFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("4"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private Flyway targetThreeFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("3"))
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
@@ -291,20 +186,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass)
|
||||
AND conname = ?
|
||||
""",
|
||||
constraintName
|
||||
)).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName)
|
||||
.isZero();
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
@@ -319,19 +200,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private String collectMessages(Throwable failure) {
|
||||
StringBuilder messages = new StringBuilder();
|
||||
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Throwable current = failure;
|
||||
while (current != null && visited.add(current)) {
|
||||
if (current.getMessage() != null) {
|
||||
messages.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
private record SeedIds(long subjectId, long semesterId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Уникальность владения дисциплинами базовой схемы")
|
||||
class SubjectOwnershipInvariantIntegrationTest {
|
||||
|
||||
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")
|
||||
void baselineEnforcesCaseInsensitiveGlobalSubjectName() throws SQLException {
|
||||
migrateBaseline();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long anotherDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
Throwable failure = catchThrowable(() -> insertSubject(
|
||||
connection,
|
||||
"информатика",
|
||||
anotherDepartmentId
|
||||
));
|
||||
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo("23505");
|
||||
assertThat(sqlFailure.getMessage()).contains(UNIQUE_INDEX);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 конкурентно принимает только одну дисциплину с одинаковым названием")
|
||||
void baselineProtectsConcurrentSubjectCreation() throws Exception {
|
||||
migrateBaseline();
|
||||
long firstDepartmentId;
|
||||
long secondDepartmentId;
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<InsertOutcome>> futures = List.of(
|
||||
executor.submit(() -> concurrentInsert("Теория систем", firstDepartmentId, ready, start)),
|
||||
executor.submit(() -> concurrentInsert("теория систем", secondDepartmentId, ready, start))
|
||||
);
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
|
||||
List<InsertOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.sqlState()).isEqualTo("23505");
|
||||
assertThat(outcome.message()).contains(UNIQUE_INDEX);
|
||||
});
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateBaseline() {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
}
|
||||
|
||||
private InsertOutcome concurrentInsert(String name,
|
||||
long departmentId,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
connection.setAutoCommit(false);
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
|
||||
}
|
||||
try {
|
||||
insertSubject(connection, name, departmentId);
|
||||
connection.commit();
|
||||
return new InsertOutcome(true, null, null);
|
||||
} catch (SQLException exception) {
|
||||
connection.rollback();
|
||||
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return new InsertOutcome(false, null, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void insertSubject(Connection connection, String name, long departmentId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO subjects (name, department_id)
|
||||
VALUES (?, ?)
|
||||
""")) {
|
||||
statement.setString(1, name);
|
||||
statement.setLong(2, departmentId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql);
|
||||
var resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
private record InsertOutcome(boolean success, String sqlState, String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Исторические назначения преподавателей базовой схемы")
|
||||
class TeacherDepartmentInvariantIntegrationTest {
|
||||
|
||||
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
|
||||
void baselineEnforcesTeacherDepartmentTimeline() throws SQLException {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long teacherId = seedTeacherId(connection);
|
||||
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
|
||||
|
||||
insertAssignment(
|
||||
connection,
|
||||
teacherId,
|
||||
secondDepartmentId,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
null,
|
||||
true
|
||||
);
|
||||
insertAssignment(
|
||||
connection,
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
assertSqlState(
|
||||
() -> insertAssignment(
|
||||
connection,
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 1),
|
||||
true
|
||||
),
|
||||
"23P01",
|
||||
OVERLAP_CONSTRAINT
|
||||
);
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM pg_constraint WHERE conname = ?",
|
||||
OVERLAP_CONSTRAINT
|
||||
)).isOne();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 отклоняет одну из двух конкурентных пересекающихся основных кафедр")
|
||||
void baselineProtectsConcurrentPrimaryAssignments() throws Exception {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
long teacherId;
|
||||
long firstDepartmentId;
|
||||
long secondDepartmentId;
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
teacherId = seedTeacherId(connection);
|
||||
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<InsertOutcome>> futures = List.of(
|
||||
executor.submit(() -> concurrentInsert(
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2090, 1, 1),
|
||||
LocalDate.of(2090, 12, 31),
|
||||
ready,
|
||||
start
|
||||
)),
|
||||
executor.submit(() -> concurrentInsert(
|
||||
teacherId,
|
||||
secondDepartmentId,
|
||||
LocalDate.of(2090, 6, 1),
|
||||
LocalDate.of(2091, 5, 31),
|
||||
ready,
|
||||
start
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
List<InsertOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
|
||||
if ("23P01".equals(outcome.sqlState())) {
|
||||
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private InsertOutcome concurrentInsert(long teacherId,
|
||||
long departmentId,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
connection.setAutoCommit(false);
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
|
||||
}
|
||||
try {
|
||||
insertAssignment(connection, teacherId, departmentId, validFrom, validTo, true);
|
||||
connection.commit();
|
||||
return new InsertOutcome(true, null, null);
|
||||
} catch (SQLException exception) {
|
||||
connection.rollback();
|
||||
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return new InsertOutcome(false, null, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway baselineFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private long seedTeacherId(Connection connection) throws SQLException {
|
||||
return queryLong(
|
||||
connection,
|
||||
"SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"
|
||||
);
|
||||
}
|
||||
|
||||
private void updateOpenPrimaryEnd(Connection connection,
|
||||
long teacherId,
|
||||
LocalDate validTo) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
UPDATE teacher_department_assignments
|
||||
SET valid_to = ?
|
||||
WHERE teacher_id = ?
|
||||
AND is_primary = TRUE
|
||||
AND valid_to IS NULL
|
||||
""")) {
|
||||
statement.setDate(1, Date.valueOf(validTo));
|
||||
statement.setLong(2, teacherId);
|
||||
assertThat(statement.executeUpdate()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertAssignment(Connection connection,
|
||||
long teacherId,
|
||||
long departmentId,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo,
|
||||
boolean primary) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO teacher_department_assignments (
|
||||
teacher_id,
|
||||
department_id,
|
||||
valid_from,
|
||||
valid_to,
|
||||
is_primary
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
statement.setLong(1, teacherId);
|
||||
statement.setLong(2, departmentId);
|
||||
statement.setDate(3, Date.valueOf(validFrom));
|
||||
statement.setObject(4, validTo == null ? null : Date.valueOf(validTo));
|
||||
statement.setBoolean(5, primary);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSqlState(ThrowingSqlRunnable operation,
|
||||
String expectedSqlState,
|
||||
String expectedMessagePart) {
|
||||
Throwable failure = catchThrowable(operation::run);
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
|
||||
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingSqlRunnable {
|
||||
void run() throws SQLException;
|
||||
}
|
||||
|
||||
private record InsertOutcome(boolean success, String sqlState, String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Инварианты временных слотов базовой схемы")
|
||||
class TimeSlotInvariantMigrationIntegrationTest {
|
||||
|
||||
private static final String DURATION_CONSTRAINT = "chk_time_slots_duration_matches_range";
|
||||
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
|
||||
void migrationEnforcesAllTimeSlotInvariants() throws SQLException {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current()).isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long manualScopeId = insertManualScope(connection, "migration_checks");
|
||||
long firstSlotId = insertTimeSlot(connection, manualScopeId, 101, "08:00", "09:30", 90);
|
||||
insertTimeSlot(connection, manualScopeId, 102, "09:30", "11:00", 90);
|
||||
|
||||
assertSqlState(
|
||||
() -> insertTimeSlot(connection, manualScopeId, 103, "09:00", "10:00", 60),
|
||||
"23P01",
|
||||
OVERLAP_CONSTRAINT
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertTimeSlot(connection, manualScopeId, 104, "11:10", "12:10", 30),
|
||||
"23514",
|
||||
DURATION_CONSTRAINT
|
||||
);
|
||||
|
||||
long usedBaseSlotId = queryLong(
|
||||
connection,
|
||||
"SELECT time_slot_id FROM schedule_rule_slots ORDER BY id LIMIT 1"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateLong(
|
||||
connection,
|
||||
"UPDATE time_slots SET time_slot_scope_id = ? WHERE id = ?",
|
||||
manualScopeId,
|
||||
usedBaseSlotId
|
||||
),
|
||||
"23514",
|
||||
"нельзя переносить из базовой сетки"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> copyRuleSlotWithTimeSlot(connection, firstSlotId),
|
||||
"23514",
|
||||
"только слоты базовой сетки"
|
||||
);
|
||||
|
||||
long defaultScopeId = queryLong(
|
||||
connection,
|
||||
"SELECT id FROM time_slot_scopes WHERE apply_mode = 'DEFAULT'"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> updateString(
|
||||
connection,
|
||||
"UPDATE time_slot_scopes SET apply_mode = ? WHERE id = ?",
|
||||
"MANUAL",
|
||||
defaultScopeId
|
||||
),
|
||||
"23514",
|
||||
"нельзя сделать небазовой"
|
||||
);
|
||||
|
||||
assertThat(constraintCount(connection, DURATION_CONSTRAINT)).isOne();
|
||||
assertThat(constraintCount(connection, OVERLAP_CONSTRAINT)).isOne();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся интервалов")
|
||||
void migrationProtectsConcurrentWrites() throws Exception {
|
||||
Flyway flyway = baselineFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
long manualScopeId;
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
manualScopeId = insertManualScope(connection, "concurrent_slots");
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<InsertOutcome>> futures = List.of(
|
||||
executor.submit(() -> concurrentInsert(
|
||||
manualScopeId, 301, "12:00", "13:30", ready, start)),
|
||||
executor.submit(() -> concurrentInsert(
|
||||
manualScopeId, 302, "13:00", "14:30", ready, start))
|
||||
);
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS))
|
||||
.as("Обе конкурентные транзакции должны подготовиться")
|
||||
.isTrue();
|
||||
start.countDown();
|
||||
|
||||
List<InsertOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
|
||||
if ("23P01".equals(outcome.sqlState())) {
|
||||
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private InsertOutcome concurrentInsert(long scopeId,
|
||||
int orderNumber,
|
||||
String startTime,
|
||||
String endTime,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
connection.setAutoCommit(false);
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
|
||||
}
|
||||
try {
|
||||
insertTimeSlot(connection, scopeId, orderNumber, startTime, endTime, 90);
|
||||
connection.commit();
|
||||
return new InsertOutcome(true, null, null);
|
||||
} catch (SQLException exception) {
|
||||
connection.rollback();
|
||||
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return new InsertOutcome(false, null, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway baselineFlyway() {
|
||||
return flyway("1");
|
||||
}
|
||||
|
||||
private Flyway flyway(String targetVersion) {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion(targetVersion))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private long insertManualScope(Connection connection, String code) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO time_slot_scopes (
|
||||
code,
|
||||
name,
|
||||
apply_mode,
|
||||
system_scope,
|
||||
display_order
|
||||
) VALUES (?, ?, 'MANUAL', FALSE, 900)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setString(1, code);
|
||||
statement.setString(2, "Тестовая ручная сетка");
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long insertTimeSlot(Connection connection,
|
||||
long scopeId,
|
||||
int orderNumber,
|
||||
String startTime,
|
||||
String endTime,
|
||||
int durationMinutes) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO time_slots (
|
||||
time_slot_scope_id,
|
||||
order_number,
|
||||
start_time,
|
||||
end_time,
|
||||
duration_minutes
|
||||
) VALUES (?, ?, ?::TIME, ?::TIME, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setLong(1, scopeId);
|
||||
statement.setInt(2, orderNumber);
|
||||
statement.setString(3, startTime);
|
||||
statement.setString(4, endTime);
|
||||
statement.setInt(5, durationMinutes);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyRuleSlotWithTimeSlot(Connection connection, long timeSlotId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_rule_slots (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
)
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
?,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
FROM schedule_rule_slots
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""")) {
|
||||
statement.setLong(1, timeSlotId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, value);
|
||||
statement.setLong(2, id);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateString(Connection connection, String sql, String value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, value);
|
||||
statement.setLong(2, id);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long constraintCount(Connection connection, String constraintName) throws SQLException {
|
||||
return queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM pg_constraint WHERE conrelid = 'time_slots'::regclass AND conname = ?",
|
||||
constraintName
|
||||
);
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSqlState(ThrowingSqlRunnable operation,
|
||||
String expectedSqlState,
|
||||
String expectedMessagePart) {
|
||||
Throwable failure = catchThrowable(operation::run);
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
|
||||
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingSqlRunnable {
|
||||
void run() throws SQLException;
|
||||
}
|
||||
|
||||
private record InsertOutcome(boolean success, String sqlState, String message) {
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -37,4 +38,17 @@ class LifecycleEntityTest {
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveUsesExplicitBusinessDateAndUtcMoment() {
|
||||
Subject subject = new Subject();
|
||||
LocalDate businessDate = LocalDate.of(2026, 1, 2);
|
||||
Instant archiveMoment = Instant.parse("2026-01-01T21:00:00Z");
|
||||
|
||||
subject.archive("Тестовая архивация", businessDate, archiveMoment);
|
||||
|
||||
assertThat(subject.getActiveTo()).isEqualTo(businessDate);
|
||||
assertThat(subject.getArchivedAt()).isEqualTo(archiveMoment);
|
||||
assertThat(subject.getArchiveReason()).isEqualTo("Тестовая архивация");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicYearDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AcademicPeriodServiceTest {
|
||||
|
||||
private static final long YEAR_ID = 10L;
|
||||
private static final long SEMESTER_ID = 20L;
|
||||
private static final LocalDate YEAR_START = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate YEAR_END = LocalDate.of(2027, 6, 30);
|
||||
|
||||
@Mock
|
||||
private AcademicYearRepository academicYearRepository;
|
||||
@Mock
|
||||
private SemesterRepository semesterRepository;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
private AcademicPeriodService service;
|
||||
private AcademicYear year;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AcademicPeriodService(
|
||||
academicYearRepository,
|
||||
semesterRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
year = year();
|
||||
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(semesterRepository.saveAndFlush(any(Semester.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsNonOverlappingYearWithTrimmedTitle() {
|
||||
AcademicYear saved = service.createYear(yearRequest(
|
||||
" 2026-2027 ",
|
||||
YEAR_START,
|
||||
YEAR_END
|
||||
));
|
||||
|
||||
assertThat(saved.getTitle()).isEqualTo("2026-2027");
|
||||
assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateTitleOrOverlappingYear() {
|
||||
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.createYear(yearRequest(
|
||||
"2026-2027",
|
||||
YEAR_START,
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом");
|
||||
|
||||
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsYearUpdateThatWouldExcludeExistingSemester() {
|
||||
Semester autumn = semester(
|
||||
SEMESTER_ID,
|
||||
SemesterType.autumn,
|
||||
YEAR_START,
|
||||
LocalDate.of(2027, 1, 31)
|
||||
);
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
|
||||
.thenReturn(List.of(autumn));
|
||||
|
||||
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest(
|
||||
"2026-2027",
|
||||
YEAR_START.plusDays(1),
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Новые границы учебного года не включают все его семестры");
|
||||
|
||||
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSemesterOutsideAcademicYear() {
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
|
||||
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
|
||||
SemesterType.autumn,
|
||||
YEAR_START.minusDays(1),
|
||||
LocalDate.of(2027, 1, 31)
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Семестр должен полностью находиться в границах учебного года");
|
||||
|
||||
verify(semesterRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateTypeOrOverlappingSemester() {
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
when(semesterRepository.existsOverlapping(
|
||||
YEAR_ID,
|
||||
YEAR_START,
|
||||
LocalDate.of(2027, 2, 1)
|
||||
)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
|
||||
SemesterType.autumn,
|
||||
YEAR_START,
|
||||
LocalDate.of(2027, 2, 1)
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
|
||||
|
||||
verify(semesterRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAdjacentSemesters() {
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
|
||||
Semester saved = service.createSemester(YEAR_ID, semesterRequest(
|
||||
SemesterType.spring,
|
||||
LocalDate.of(2027, 2, 1),
|
||||
YEAR_END
|
||||
));
|
||||
|
||||
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
|
||||
assertThat(saved.getAcademicYear()).isSameAs(year);
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesDuplicateSemesterTypeDuringUpdate() {
|
||||
Semester existing = semester(
|
||||
SEMESTER_ID,
|
||||
SemesterType.spring,
|
||||
LocalDate.of(2027, 2, 1),
|
||||
YEAR_END
|
||||
);
|
||||
when(semesterRepository.findById(SEMESTER_ID)).thenReturn(Optional.of(existing));
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(existing));
|
||||
when(semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
|
||||
YEAR_ID,
|
||||
SemesterType.autumn,
|
||||
SEMESTER_ID
|
||||
)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateSemester(SEMESTER_ID, semesterRequest(
|
||||
SemesterType.autumn,
|
||||
LocalDate.of(2027, 2, 1),
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
|
||||
|
||||
verify(semesterRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsDatabaseRaceToSafeConflict() {
|
||||
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
|
||||
.thenThrow(new DataIntegrityViolationException("ex_academic_years_no_overlap"));
|
||||
|
||||
assertThatThrownBy(() -> service.createYear(yearRequest(
|
||||
"2026-2027",
|
||||
YEAR_START,
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом")
|
||||
.hasNoCause();
|
||||
}
|
||||
|
||||
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
|
||||
return new AcademicYearDto(null, title, startDate, endDate, List.of());
|
||||
}
|
||||
|
||||
private SemesterDto semesterRequest(SemesterType semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return new SemesterDto(
|
||||
null,
|
||||
YEAR_ID,
|
||||
"2026-2027",
|
||||
semesterType,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicYear year() {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(YEAR_ID);
|
||||
academicYear.setTitle("2026-2027");
|
||||
academicYear.setStartDate(YEAR_START);
|
||||
academicYear.setEndDate(YEAR_END);
|
||||
return academicYear;
|
||||
}
|
||||
|
||||
private Semester semester(Long id,
|
||||
SemesterType semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Semester semester = new Semester();
|
||||
semester.setId(id);
|
||||
semester.setAcademicYear(year);
|
||||
semester.setSemesterType(semesterType);
|
||||
semester.setStartDate(startDate);
|
||||
semester.setEndDate(endDate);
|
||||
return semester;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AcademicStructureServiceTest {
|
||||
|
||||
private static final long YEAR_ID = 10L;
|
||||
private static final long SPECIALITY_ID = 20L;
|
||||
private static final long PROFILE_ID = 30L;
|
||||
private static final long FORM_ID = 40L;
|
||||
private static final long OTHER_FORM_ID = 41L;
|
||||
private static final long CALENDAR_ID = 50L;
|
||||
private static final long GROUP_ID = 60L;
|
||||
|
||||
@Mock
|
||||
private AcademicCalendarRepository calendarRepository;
|
||||
@Mock
|
||||
private AcademicCalendarDayRepository calendarDayRepository;
|
||||
@Mock
|
||||
private AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
@Mock
|
||||
private AcademicYearRepository academicYearRepository;
|
||||
@Mock
|
||||
private SpecialtiesRepository specialtiesRepository;
|
||||
@Mock
|
||||
private SpecialtyProfileRepository profileRepository;
|
||||
@Mock
|
||||
private EducationFormRepository educationFormRepository;
|
||||
@Mock
|
||||
private GroupRepository groupRepository;
|
||||
@Mock
|
||||
private StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
private AcademicStructureService service;
|
||||
private AcademicYear academicYear;
|
||||
private Speciality speciality;
|
||||
private SpecialtyProfile profile;
|
||||
private EducationForm studyForm;
|
||||
private EducationForm otherStudyForm;
|
||||
private AcademicCalendar calendar;
|
||||
private StudentGroup group;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AcademicStructureService(
|
||||
calendarRepository,
|
||||
calendarDayRepository,
|
||||
calendarSubjectRepository,
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
profileRepository,
|
||||
educationFormRepository,
|
||||
groupRepository,
|
||||
assignmentRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
academicYear = academicYear();
|
||||
speciality = speciality();
|
||||
profile = profile(speciality);
|
||||
studyForm = studyForm(FORM_ID, "Бакалавриат");
|
||||
otherStudyForm = studyForm(OTHER_FORM_ID, "Магистратура");
|
||||
calendar = calendar();
|
||||
group = group(2025);
|
||||
|
||||
when(calendarRepository.findByIdWithDetailsForUpdate(CALENDAR_ID))
|
||||
.thenReturn(Optional.of(calendar));
|
||||
when(academicYearRepository.findById(YEAR_ID)).thenReturn(Optional.of(academicYear));
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(academicYear));
|
||||
when(specialtiesRepository.findById(SPECIALITY_ID)).thenReturn(Optional.of(speciality));
|
||||
when(profileRepository.findById(PROFILE_ID)).thenReturn(Optional.of(profile));
|
||||
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
|
||||
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
|
||||
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
|
||||
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
|
||||
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))
|
||||
.thenReturn(List.of(assignment(group, calendar)));
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalendar(
|
||||
CALENDAR_ID,
|
||||
calendarRequest(4, OTHER_FORM_ID)
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Нельзя изменить график: сохранённые назначения групп станут несовместимыми");
|
||||
|
||||
assertThat(calendar.getStudyForm()).isSameAs(studyForm);
|
||||
verify(calendarRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCourseCountBelowExistingGrid() {
|
||||
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
CALENDAR_ID, 3)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalendar(
|
||||
CALENDAR_ID,
|
||||
calendarRequest(3, FORM_ID)
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Нельзя уменьшить количество курсов: в сетке есть данные старших курсов");
|
||||
|
||||
assertThat(calendar.getCourseCount()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCourseCountBelowExistingSubjects() {
|
||||
when(calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
|
||||
CALENDAR_ID, 6)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalendar(
|
||||
CALENDAR_ID,
|
||||
calendarRequest(3, FORM_ID)
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGroupDimensionChangeAndKeepsAssignment() {
|
||||
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
|
||||
.thenReturn(List.of(assignment(group, calendar)));
|
||||
|
||||
assertThatThrownBy(() -> service.updateGroup(
|
||||
GROUP_ID,
|
||||
groupRequest(2025, OTHER_FORM_ID)
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми");
|
||||
|
||||
assertThat(group.getEducationForm()).isSameAs(studyForm);
|
||||
verify(groupRepository, never()).saveAndFlush(any());
|
||||
verify(assignmentRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCompatibleGroupUpdateAndClearsCache() {
|
||||
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
|
||||
.thenReturn(List.of(assignment(group, calendar)));
|
||||
|
||||
StudentGroup saved = service.updateGroup(GROUP_ID, groupRequest(2025, FORM_ID));
|
||||
|
||||
assertThat(saved.getName()).isEqualTo("ИВТ-26");
|
||||
assertThat(saved.getEducationForm()).isSameAs(studyForm);
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
|
||||
group.setYearStartStudy(2020);
|
||||
|
||||
assertThatThrownBy(() -> service.saveAssignment(
|
||||
GROUP_ID,
|
||||
assignmentRequest()
|
||||
))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Календарный график не соответствует учебному году, параметрам или курсу группы");
|
||||
|
||||
verify(assignmentRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesCompatibleAssignmentWithoutDeletingExistingData() {
|
||||
when(assignmentRepository.findByGroupIdAndAcademicYearIdForUpdate(GROUP_ID, YEAR_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
StudentGroupCalendarAssignment saved = service.saveAssignment(
|
||||
GROUP_ID,
|
||||
assignmentRequest()
|
||||
);
|
||||
|
||||
assertThat(saved.getStudentGroup()).isSameAs(group);
|
||||
assertThat(saved.getAcademicCalendar()).isSameAs(calendar);
|
||||
verify(assignmentRepository, never()).delete(any());
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) {
|
||||
return new AcademicCalendarDto(
|
||||
CALENDAR_ID,
|
||||
"График 2026",
|
||||
YEAR_ID,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
SPECIALITY_ID,
|
||||
null,
|
||||
null,
|
||||
PROFILE_ID,
|
||||
null,
|
||||
studyFormId,
|
||||
null,
|
||||
courseCount
|
||||
);
|
||||
}
|
||||
|
||||
private CreateGroupRequest groupRequest(int yearStartStudy, long studyFormId) {
|
||||
CreateGroupRequest request = new CreateGroupRequest();
|
||||
request.setName("ИВТ-26");
|
||||
request.setGroupSize(25L);
|
||||
request.setEducationFormId(studyFormId);
|
||||
request.setDepartmentId(1L);
|
||||
request.setYearStartStudy(yearStartStudy);
|
||||
request.setSpecialtyId(SPECIALITY_ID);
|
||||
request.setSpecialtyProfileId(PROFILE_ID);
|
||||
return request;
|
||||
}
|
||||
|
||||
private GroupCalendarAssignmentDto assignmentRequest() {
|
||||
return new GroupCalendarAssignmentDto(
|
||||
null,
|
||||
GROUP_ID,
|
||||
null,
|
||||
YEAR_ID,
|
||||
null,
|
||||
CALENDAR_ID,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicYear academicYear() {
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(YEAR_ID);
|
||||
year.setTitle("2025-2026");
|
||||
year.setStartDate(LocalDate.of(2025, 9, 1));
|
||||
year.setEndDate(LocalDate.of(2026, 6, 30));
|
||||
return year;
|
||||
}
|
||||
|
||||
private Speciality speciality() {
|
||||
Speciality value = new Speciality();
|
||||
value.setId(SPECIALITY_ID);
|
||||
value.setSpecialityCode("09.03.01");
|
||||
value.setSpecialityName("Информатика");
|
||||
return value;
|
||||
}
|
||||
|
||||
private SpecialtyProfile profile(Speciality owner) {
|
||||
SpecialtyProfile value = new SpecialtyProfile();
|
||||
value.setId(PROFILE_ID);
|
||||
value.setName("Без профиля");
|
||||
value.setSpeciality(owner);
|
||||
return value;
|
||||
}
|
||||
|
||||
private EducationForm studyForm(long id, String name) {
|
||||
EducationForm value = new EducationForm();
|
||||
value.setId(id);
|
||||
value.setName(name);
|
||||
return value;
|
||||
}
|
||||
|
||||
private AcademicCalendar calendar() {
|
||||
AcademicCalendar value = new AcademicCalendar();
|
||||
value.setId(CALENDAR_ID);
|
||||
value.setTitle("Исходный график");
|
||||
value.setAcademicYear(academicYear);
|
||||
value.setSpeciality(speciality);
|
||||
value.setSpecialtyProfile(profile);
|
||||
value.setStudyForm(studyForm);
|
||||
value.setCourseCount(4);
|
||||
return value;
|
||||
}
|
||||
|
||||
private StudentGroup group(int yearStartStudy) {
|
||||
StudentGroup value = new StudentGroup();
|
||||
value.setId(GROUP_ID);
|
||||
value.setName("ИВТ-25");
|
||||
value.setGroupSize(25L);
|
||||
value.setEducationForm(studyForm);
|
||||
value.setDepartmentId(1L);
|
||||
value.setYearStartStudy(yearStartStudy);
|
||||
value.setSpeciality(speciality);
|
||||
value.setSpecialtyProfile(profile);
|
||||
return value;
|
||||
}
|
||||
|
||||
private StudentGroupCalendarAssignment assignment(StudentGroup assignedGroup,
|
||||
AcademicCalendar assignedCalendar) {
|
||||
StudentGroupCalendarAssignment value = new StudentGroupCalendarAssignment();
|
||||
value.setId(70L);
|
||||
value.setStudentGroup(assignedGroup);
|
||||
value.setAcademicYear(academicYear);
|
||||
value.setAcademicCalendar(assignedCalendar);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BusinessTimeServiceTest {
|
||||
|
||||
private static final ZoneId MOSCOW = ZoneId.of("Europe/Moscow");
|
||||
|
||||
@Test
|
||||
void keepsBusinessDateBeforeMoscowMidnight() {
|
||||
BusinessTimeService service = new BusinessTimeService(
|
||||
Clock.fixed(Instant.parse("2026-01-01T20:59:59Z"), ZoneId.of("UTC")),
|
||||
MOSCOW
|
||||
);
|
||||
|
||||
assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 1));
|
||||
assertThat(service.now()).isEqualTo(Instant.parse("2026-01-01T20:59:59Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesBusinessDateExactlyAtMoscowMidnight() {
|
||||
BusinessTimeService service = new BusinessTimeService(
|
||||
Clock.fixed(Instant.parse("2026-01-01T21:00:00Z"), ZoneId.of("UTC")),
|
||||
MOSCOW
|
||||
);
|
||||
|
||||
assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("Число запросов генератора расписания")
|
||||
class ScheduleGenerationQueryCountTest {
|
||||
|
||||
private static final LocalDate START_DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate END_DATE = LocalDate.of(2026, 12, 29);
|
||||
private static final int GROUP_COUNT = 30;
|
||||
private static final int RULE_COUNT = 20;
|
||||
|
||||
@Test
|
||||
@DisplayName("120 дат, десятки групп и правил обрабатываются семью batch-запросами")
|
||||
void queryCountDoesNotGrowAsDatesTimesGroupsTimesRules() {
|
||||
SemesterRepository semesterRepository = mock(SemesterRepository.class);
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository =
|
||||
mock(StudentGroupCalendarAssignmentRepository.class);
|
||||
AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class);
|
||||
ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class);
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class);
|
||||
TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class);
|
||||
TimeSlotRepository timeSlotRepository = mock(TimeSlotRepository.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
|
||||
AcademicYear academicYear = academicYear();
|
||||
Semester semester = semester(academicYear);
|
||||
AcademicCalendar calendar = calendar(academicYear);
|
||||
List<StudentGroup> groups = groups();
|
||||
List<StudentGroupCalendarAssignment> assignments = groups.stream()
|
||||
.map(group -> assignment(group, academicYear, calendar))
|
||||
.toList();
|
||||
List<AcademicCalendarDay> calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1))
|
||||
.map(date -> calendarDay(calendar, date))
|
||||
.toList();
|
||||
List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT)
|
||||
.mapToObj(index -> rule(index, semester, groups))
|
||||
.toList();
|
||||
TimeSlotScope defaultScope = timeSlotScope(40L, "DEFAULT", null);
|
||||
TimeSlotScope weekdayScope = timeSlotScope(41L, "WEEKDAY", 2);
|
||||
TimeSlot baseSlot = timeSlot(50L, defaultScope, LocalTime.of(8, 0), LocalTime.of(9, 30));
|
||||
TimeSlot effectiveSlot = timeSlot(51L, weekdayScope, LocalTime.of(8, 15), LocalTime.of(9, 45));
|
||||
addRenderedLecture(rules.get(0), baseSlot);
|
||||
|
||||
when(semesterRepository.findOverlappingForSchedule(START_DATE, END_DATE))
|
||||
.thenReturn(List.of(semester));
|
||||
when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection()))
|
||||
.thenReturn(assignments);
|
||||
when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)))
|
||||
.thenReturn(calendarDays);
|
||||
when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()))
|
||||
.thenReturn(rules);
|
||||
when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of());
|
||||
when(scopeRepository.findByApplyModeOrderByDisplayOrderAsc("WEEKDAY"))
|
||||
.thenReturn(List.of(weekdayScope));
|
||||
when(timeSlotRepository.findAllForSchedule()).thenReturn(List.of(baseSlot, effectiveSlot));
|
||||
|
||||
AcademicDateService academicDateService = new AcademicDateService(
|
||||
semesterRepository,
|
||||
calendarDayRepository,
|
||||
assignmentRepository,
|
||||
false
|
||||
);
|
||||
ScheduleGeneratorService generator = new ScheduleGeneratorService(
|
||||
ruleRepository,
|
||||
groupRepository,
|
||||
userRepository,
|
||||
academicDateService,
|
||||
timeSlotRepository,
|
||||
scopeRepository,
|
||||
dateAssignmentRepository
|
||||
);
|
||||
|
||||
var result = generator.buildScheduleForGroups(groups, START_DATE, END_DATE);
|
||||
|
||||
assertThat(START_DATE.datesUntil(END_DATE.plusDays(1)).count()).isEqualTo(120);
|
||||
assertThat(groups).hasSize(GROUP_COUNT);
|
||||
assertThat(rules).hasSize(RULE_COUNT);
|
||||
assertThat(result).hasSize(GROUP_COUNT).allSatisfy(lesson -> {
|
||||
assertThat(lesson.date()).isEqualTo(START_DATE);
|
||||
assertThat(lesson.timeSlotId()).isEqualTo(effectiveSlot.getId());
|
||||
assertThat(lesson.startTime()).isEqualTo(effectiveSlot.getStartTime());
|
||||
assertThat(lesson.remainingLessonTypeAcademicHoursAfterLesson()).isZero();
|
||||
assertThat(lesson.groupIds()).hasSize(1);
|
||||
});
|
||||
|
||||
verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE);
|
||||
verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection());
|
||||
verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE));
|
||||
verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection());
|
||||
verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE);
|
||||
verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY");
|
||||
verify(timeSlotRepository).findAllForSchedule();
|
||||
|
||||
verify(semesterRepository, never())
|
||||
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any());
|
||||
verify(assignmentRepository, never()).findForSchedule(any(), any());
|
||||
verify(calendarDayRepository, never())
|
||||
.findByCalendarIdAndCourseNumberAndDate(any(), any(), any());
|
||||
verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any());
|
||||
verify(dateAssignmentRepository, never()).findByDate(any());
|
||||
verify(scopeRepository, never()).findByApplyModeAndDayOfWeek(any(), any());
|
||||
verify(timeSlotRepository, never()).findByTimeSlotScopeIdAndOrderNumber(any(), any());
|
||||
|
||||
int repositoryCalls = Stream.of(
|
||||
semesterRepository,
|
||||
assignmentRepository,
|
||||
calendarDayRepository,
|
||||
ruleRepository,
|
||||
dateAssignmentRepository,
|
||||
scopeRepository,
|
||||
timeSlotRepository,
|
||||
groupRepository,
|
||||
userRepository
|
||||
)
|
||||
.mapToInt(repository -> mockingDetails(repository).getInvocations().size())
|
||||
.sum();
|
||||
assertThat(repositoryCalls).isEqualTo(7);
|
||||
}
|
||||
|
||||
private AcademicYear academicYear() {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(1L);
|
||||
academicYear.setTitle("2026/2027");
|
||||
academicYear.setStartDate(START_DATE);
|
||||
academicYear.setEndDate(LocalDate.of(2027, 8, 31));
|
||||
return academicYear;
|
||||
}
|
||||
|
||||
private Semester semester(AcademicYear academicYear) {
|
||||
Semester semester = new Semester();
|
||||
semester.setId(10L);
|
||||
semester.setAcademicYear(academicYear);
|
||||
semester.setSemesterType(SemesterType.autumn);
|
||||
semester.setStartDate(START_DATE);
|
||||
semester.setEndDate(END_DATE);
|
||||
return semester;
|
||||
}
|
||||
|
||||
private AcademicCalendar calendar(AcademicYear academicYear) {
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setId(20L);
|
||||
calendar.setTitle("Основной учебный график");
|
||||
calendar.setAcademicYear(academicYear);
|
||||
calendar.setCourseCount(4);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
private List<StudentGroup> groups() {
|
||||
return IntStream.rangeClosed(1, GROUP_COUNT)
|
||||
.mapToObj(index -> {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId((long) index);
|
||||
group.setName("Группа " + index);
|
||||
group.setYearStartStudy(2026);
|
||||
return group;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private StudentGroupCalendarAssignment assignment(StudentGroup group,
|
||||
AcademicYear academicYear,
|
||||
AcademicCalendar calendar) {
|
||||
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
|
||||
assignment.setId(100L + group.getId());
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
return assignment;
|
||||
}
|
||||
|
||||
private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) {
|
||||
AcademicCalendarActivityType theory = new AcademicCalendarActivityType();
|
||||
theory.setId(30L);
|
||||
theory.setCode("Т");
|
||||
theory.setName("Теоретическое обучение");
|
||||
theory.setAllowSchedule(true);
|
||||
|
||||
AcademicCalendarDay day = new AcademicCalendarDay();
|
||||
day.setAcademicCalendar(calendar);
|
||||
day.setCourseNumber(1);
|
||||
day.setDate(date);
|
||||
day.setActivityType(theory);
|
||||
return day;
|
||||
}
|
||||
|
||||
private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) {
|
||||
Subject subject = new Subject((long) index, "Дисциплина " + index, "D" + index, 1L);
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setId((long) index);
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setGroups(new HashSet<>(groups));
|
||||
return rule;
|
||||
}
|
||||
|
||||
private TimeSlotScope timeSlotScope(Long id, String applyMode, Integer dayOfWeek) {
|
||||
TimeSlotScope scope = new TimeSlotScope();
|
||||
scope.setId(id);
|
||||
scope.setCode(applyMode + "-" + id);
|
||||
scope.setName("Сетка " + id);
|
||||
scope.setApplyMode(applyMode);
|
||||
scope.setDayOfWeek(dayOfWeek);
|
||||
scope.setDisplayOrder(id.intValue());
|
||||
return scope;
|
||||
}
|
||||
|
||||
private TimeSlot timeSlot(Long id, TimeSlotScope scope, LocalTime startTime, LocalTime endTime) {
|
||||
TimeSlot slot = new TimeSlot();
|
||||
slot.setId(id);
|
||||
slot.setOrderNumber(1);
|
||||
slot.setTimeSlotScope(scope);
|
||||
slot.setStartTime(startTime);
|
||||
slot.setEndTime(endTime);
|
||||
slot.setDurationMinutes(90);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void addRenderedLecture(ScheduleRule rule, TimeSlot timeSlot) {
|
||||
User teacher = new User();
|
||||
teacher.setId(60L);
|
||||
teacher.setUsername("teacher");
|
||||
teacher.setFullName("Тестовый преподаватель");
|
||||
teacher.setRole(Role.TEACHER);
|
||||
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(70L);
|
||||
classroom.setName("А-101");
|
||||
classroom.setIsAvailable(true);
|
||||
|
||||
LessonType lessonType = new LessonType();
|
||||
lessonType.setId(80L);
|
||||
lessonType.setLessonType("Лекция");
|
||||
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setId(90L);
|
||||
slot.setScheduleRule(rule);
|
||||
slot.setDayOfWeek(2);
|
||||
slot.setParity(ScheduleParity.BOTH);
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
slot.setLessonFormat("Очно");
|
||||
|
||||
rule.setLectureAcademicHours(2);
|
||||
rule.setSlots(new HashSet<>(List.of(slot)));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -67,7 +69,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
assertThat(sourceSchedule).isEmpty();
|
||||
verify(overrideRepository, times(2)).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
verifyBaseDayBatch();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,7 +87,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
assertThat(result).isEmpty();
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
verifyBaseDayBatch();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,7 +111,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
.containsExactly(101L, 102L);
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
verifyBaseDayBatch();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -128,7 +130,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
verifyBaseDayBatch();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -146,7 +148,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
move.setNewTimeSlot(newTimeSlot);
|
||||
when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group()));
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE))
|
||||
when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE)))
|
||||
.thenReturn(List.of(cancelledLesson, movedLesson));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(cancellation, move));
|
||||
@@ -173,7 +175,15 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
private void stubBaseDay(List<RenderedLessonDto> lessons) {
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group()));
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE)).thenReturn(lessons);
|
||||
when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE))).thenReturn(lessons);
|
||||
}
|
||||
|
||||
private void verifyBaseDayBatch() {
|
||||
verify(generatorService).buildScheduleForGroups(
|
||||
argThat(groups -> groups.size() == 1 && groups.iterator().next().getId().equals(GROUP_ID)),
|
||||
eq(DATE),
|
||||
eq(DATE)
|
||||
);
|
||||
}
|
||||
|
||||
private StudentGroup group() {
|
||||
|
||||
@@ -4,13 +4,18 @@ import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -68,6 +73,40 @@ class ScheduleQueryServiceTest {
|
||||
verify(groupRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {51, 100})
|
||||
void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) {
|
||||
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 5, 7);
|
||||
when(groupRepository.findAll())
|
||||
.thenReturn(IntStream.rangeClosed(1, groupCount)
|
||||
.mapToObj(this::group)
|
||||
.toList());
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
when(generatorService.buildScheduleForGroups(any(), any(), any())).thenReturn(List.of());
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
generatorService,
|
||||
groupRepository,
|
||||
overrideRepository,
|
||||
groupLifecycleService
|
||||
);
|
||||
|
||||
var result = service.searchForAggregation(null, null, startDate, endDate);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(generatorService).buildScheduleForGroups(
|
||||
argThat(groups -> groups.size() == groupCount),
|
||||
eq(startDate),
|
||||
eq(endDate)
|
||||
);
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate);
|
||||
}
|
||||
|
||||
private StudentGroup group(int id) {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId((long) id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user