Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule
This commit is contained in:
85
.agents/skills/AutoUpdateDocs.md
Normal file
85
.agents/skills/AutoUpdateDocs.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: AutoUpdateDocs
|
||||||
|
description: Автоматическое обновление документации проекта после изменений в коде
|
||||||
|
---
|
||||||
|
|
||||||
|
# Скилл: Автоматическое обновление документации
|
||||||
|
|
||||||
|
## Когда активировать
|
||||||
|
|
||||||
|
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
|
||||||
|
|
||||||
|
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
|
||||||
|
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
|
||||||
|
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
|
||||||
|
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
|
||||||
|
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
|
||||||
|
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
|
||||||
|
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
|
||||||
|
- **Общая структура проекта** → обновить `docs/README.md`
|
||||||
|
|
||||||
|
## Карта соответствия «файл → документация»
|
||||||
|
|
||||||
|
| Изменённый файл/директория | Файл документации |
|
||||||
|
|----------------------------|-------------------|
|
||||||
|
| `controller/*Controller.java` | `docs/API.md` |
|
||||||
|
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
|
||||||
|
| `model/*.java` | `docs/DATABASE.md` |
|
||||||
|
| `dto/*.java` | `docs/API.md` |
|
||||||
|
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
|
||||||
|
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
|
||||||
|
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
|
||||||
|
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
|
||||||
|
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
|
||||||
|
| `application.properties` | `docs/ARCHITECTURE.md` |
|
||||||
|
|
||||||
|
## Пошаговая инструкция
|
||||||
|
|
||||||
|
### 1. Определить затронутые файлы документации
|
||||||
|
|
||||||
|
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
|
||||||
|
|
||||||
|
### 2. Прочитать текущую документацию
|
||||||
|
|
||||||
|
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
|
||||||
|
|
||||||
|
### 3. Внести точечные изменения
|
||||||
|
|
||||||
|
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
|
||||||
|
|
||||||
|
#### Новый контроллер → `docs/API.md`
|
||||||
|
Добавить новую секцию с описанием эндпоинтов:
|
||||||
|
- Метод + URL
|
||||||
|
- Тело запроса (JSON пример)
|
||||||
|
- Ответ (JSON пример)
|
||||||
|
- Валидация
|
||||||
|
|
||||||
|
#### Новая миграция → `docs/DATABASE.md`
|
||||||
|
- Добавить новую таблицу в ER-диаграмму (Mermaid)
|
||||||
|
- Добавить описание таблицы и колонок
|
||||||
|
- Добавить запись в таблицу «Текущие миграции»
|
||||||
|
|
||||||
|
#### Новый view → `docs/FRONTEND.md`
|
||||||
|
- Добавить в дерево файлов
|
||||||
|
- Добавить в таблицу «Разделы админ-панели»
|
||||||
|
|
||||||
|
### 4. Обновить AGENTS.md (при необходимости)
|
||||||
|
|
||||||
|
Если изменения затрагивают:
|
||||||
|
- Структуру директорий → обновить дерево в `AGENTS.md`
|
||||||
|
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
|
||||||
|
|
||||||
|
### 5. Сообщить пользователю
|
||||||
|
|
||||||
|
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
|
||||||
|
|
||||||
|
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
1. **Язык:** Вся документация на русском языке
|
||||||
|
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
|
||||||
|
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
|
||||||
|
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
|
||||||
|
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
|
||||||
|
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
web_search = "live"
|
web_search = "live"
|
||||||
|
|
||||||
developer_instructions = """
|
developer_instructions = """
|
||||||
Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент.
|
Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
|
||||||
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
|
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
|
||||||
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
|
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
|
||||||
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
|
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
db/data/
|
db/data/
|
||||||
.env
|
.env
|
||||||
|
backend/tenants.json
|
||||||
|
|
||||||
# Игнорируем временные файлы сборки (на будущее)
|
# Игнорируем временные файлы сборки (на будущее)
|
||||||
backend/target/
|
backend/target/
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ docker compose logs -f backend
|
|||||||
## Критические правила для агентов
|
## Критические правила для агентов
|
||||||
|
|
||||||
### Flyway миграции
|
### Flyway миграции
|
||||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway.
|
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
||||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
||||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@
|
|||||||
<artifactId>spring-security-crypto</artifactId>
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT/JWS support without enabling full Spring Security auto-flow -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
|
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
@@ -63,6 +69,13 @@
|
|||||||
<artifactId>opentelemetry-api</artifactId>
|
<artifactId>opentelemetry-api</artifactId>
|
||||||
<version>1.49.0</version>
|
<version>1.49.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Tests -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.magistr.app.config.auth;
|
|
||||||
|
|
||||||
import com.magistr.app.model.User;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class AuthSessionService {
|
|
||||||
|
|
||||||
private final Map<String, AuthenticatedUser> sessions = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String createSession(User user) {
|
|
||||||
String token = UUID.randomUUID().toString();
|
|
||||||
sessions.put(token, new AuthenticatedUser(
|
|
||||||
user.getId(),
|
|
||||||
user.getUsername(),
|
|
||||||
user.getRole(),
|
|
||||||
user.getDepartmentId()
|
|
||||||
));
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<AuthenticatedUser> findByToken(String token) {
|
|
||||||
if (token == null || token.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.ofNullable(sessions.get(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,11 +15,11 @@ import java.util.Map;
|
|||||||
@Component
|
@Component
|
||||||
public class AuthorizationInterceptor implements HandlerInterceptor {
|
public class AuthorizationInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private final AuthSessionService sessionService;
|
private final JwtTokenService jwtTokenService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) {
|
public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
|
||||||
this.sessionService = sessionService;
|
this.jwtTokenService = jwtTokenService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +28,15 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
|
|||||||
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (request.getRequestURI().equals("/api/auth/login")) {
|
if (isPublicAuthEndpoint(request)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = bearerToken(request.getHeader("Authorization"));
|
String token = bearerToken(request.getHeader("Authorization"));
|
||||||
AuthenticatedUser user = sessionService.findByToken(token).orElse(null);
|
AuthenticatedUser user = jwtTokenService.authenticate(
|
||||||
|
token,
|
||||||
|
com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
|
||||||
|
).orElse(null);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
|
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
|
||||||
return false;
|
return false;
|
||||||
@@ -49,6 +52,16 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isPublicAuthEndpoint(HttpServletRequest request) {
|
||||||
|
if (!request.getRequestURI().startsWith("/api/auth/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return "POST".equalsIgnoreCase(request.getMethod())
|
||||||
|
&& (request.getRequestURI().equals("/api/auth/login")
|
||||||
|
|| request.getRequestURI().equals("/api/auth/refresh")
|
||||||
|
|| request.getRequestURI().equals("/api/auth/logout"));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
AuthContext.clear();
|
AuthContext.clear();
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.jwt")
|
||||||
|
public class JwtProperties {
|
||||||
|
|
||||||
|
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
|
||||||
|
private Duration accessTtl = Duration.ofMinutes(15);
|
||||||
|
private Duration refreshTtl = Duration.ofDays(7);
|
||||||
|
private String refreshCookieName = "magistr_refresh";
|
||||||
|
private boolean refreshCookieSecure = false;
|
||||||
|
|
||||||
|
public String getSecret() {
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecret(String secret) {
|
||||||
|
this.secret = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getAccessTtl() {
|
||||||
|
return accessTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessTtl(Duration accessTtl) {
|
||||||
|
this.accessTtl = accessTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getRefreshTtl() {
|
||||||
|
return refreshTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshTtl(Duration refreshTtl) {
|
||||||
|
this.refreshTtl = refreshTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshCookieName() {
|
||||||
|
return refreshCookieName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshCookieName(String refreshCookieName) {
|
||||||
|
this.refreshCookieName = refreshCookieName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRefreshCookieSecure() {
|
||||||
|
return refreshCookieSecure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshCookieSecure(boolean refreshCookieSecure) {
|
||||||
|
this.refreshCookieSecure = refreshCookieSecure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] secretBytes() {
|
||||||
|
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length < 32) {
|
||||||
|
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||||
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JwtTokenService {
|
||||||
|
|
||||||
|
private static final String ISSUER = "magistr";
|
||||||
|
|
||||||
|
private final JwtProperties properties;
|
||||||
|
private final JwtEncoder encoder;
|
||||||
|
private final JwtDecoder decoder;
|
||||||
|
|
||||||
|
public JwtTokenService(JwtProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256");
|
||||||
|
this.encoder = new NimbusJwtEncoder(new ImmutableSecret<SecurityContext>(secretKey));
|
||||||
|
this.decoder = NimbusJwtDecoder
|
||||||
|
.withSecretKey(secretKey)
|
||||||
|
.macAlgorithm(MacAlgorithm.HS256)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createAccessToken(User user, String tenant) {
|
||||||
|
return createAccessToken(new AuthenticatedUser(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getRole(),
|
||||||
|
user.getDepartmentId()
|
||||||
|
), tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createAccessToken(AuthenticatedUser user, String tenant) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
|
||||||
|
.issuer(ISSUER)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiresAt(now.plus(properties.getAccessTtl()))
|
||||||
|
.subject(String.valueOf(user.id()))
|
||||||
|
.id(UUID.randomUUID().toString())
|
||||||
|
.claim("tenant", tenant)
|
||||||
|
.claim("userId", user.id())
|
||||||
|
.claim("username", user.username())
|
||||||
|
.claim("role", user.role().name());
|
||||||
|
|
||||||
|
if (user.departmentId() != null) {
|
||||||
|
claims.claim("departmentId", user.departmentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
|
||||||
|
return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AuthenticatedUser> authenticate(String token, String expectedTenant) {
|
||||||
|
if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Jwt jwt = decoder.decode(token);
|
||||||
|
String tenant = jwt.getClaimAsString("tenant");
|
||||||
|
if (!expectedTenant.equalsIgnoreCase(tenant)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
|
||||||
|
String username = jwt.getClaimAsString("username");
|
||||||
|
Role role = Role.valueOf(jwt.getClaimAsString("role"));
|
||||||
|
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
|
||||||
|
|
||||||
|
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Long> asLong(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return Optional.of(number.longValue());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(Long.valueOf(String.valueOf(value)));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
|
||||||
|
public record RefreshTokenRotation(User user, String refreshToken) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
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.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.util.Base64;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RefreshTokenService {
|
||||||
|
|
||||||
|
private static final int TOKEN_BYTES = 32;
|
||||||
|
private static final int USER_AGENT_LIMIT = 512;
|
||||||
|
private static final int IP_LIMIT = 64;
|
||||||
|
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
private final AuthRefreshTokenRepository repository;
|
||||||
|
private final JwtProperties properties;
|
||||||
|
|
||||||
|
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||||
|
String refreshToken = generateRawToken();
|
||||||
|
AuthRefreshToken token = new AuthRefreshToken();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
token.setUser(user);
|
||||||
|
token.setTenant(tenant);
|
||||||
|
token.setTokenHash(hashToken(refreshToken));
|
||||||
|
token.setIssuedAt(now);
|
||||||
|
token.setExpiresAt(now.plus(properties.getRefreshTtl()));
|
||||||
|
token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
|
||||||
|
token.setIpAddress(limit(ipAddress, IP_LIMIT));
|
||||||
|
repository.save(token);
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<RefreshTokenRotation> rotate(String rawToken, String tenant, String userAgent, String ipAddress) {
|
||||||
|
if (rawToken == null || rawToken.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
String currentHash = hashToken(rawToken);
|
||||||
|
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
|
||||||
|
if (storedOpt.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRefreshToken stored = storedOpt.get();
|
||||||
|
if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = stored.getUser();
|
||||||
|
if (user.isArchivedRecord()) {
|
||||||
|
stored.setRevokedAt(now);
|
||||||
|
repository.save(stored);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String newRawToken = generateRawToken();
|
||||||
|
String newHash = hashToken(newRawToken);
|
||||||
|
stored.setRevokedAt(now);
|
||||||
|
stored.setRotatedToTokenHash(newHash);
|
||||||
|
repository.save(stored);
|
||||||
|
|
||||||
|
AuthRefreshToken next = new AuthRefreshToken();
|
||||||
|
next.setUser(user);
|
||||||
|
next.setTenant(tenant);
|
||||||
|
next.setTokenHash(newHash);
|
||||||
|
next.setIssuedAt(now);
|
||||||
|
next.setExpiresAt(now.plus(properties.getRefreshTtl()));
|
||||||
|
next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
|
||||||
|
next.setIpAddress(limit(ipAddress, IP_LIMIT));
|
||||||
|
repository.save(next);
|
||||||
|
|
||||||
|
return Optional.of(new RefreshTokenRotation(user, newRawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void revoke(String rawToken, String tenant) {
|
||||||
|
if (rawToken == null || rawToken.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.findByTokenHash(hashToken(rawToken))
|
||||||
|
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||||
|
.filter(token -> token.getRevokedAt() == null)
|
||||||
|
.ifPresent(token -> {
|
||||||
|
token.setRevokedAt(LocalDateTime.now());
|
||||||
|
repository.save(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String hashToken(String rawToken) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(hash);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 недоступен", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRawToken() {
|
||||||
|
byte[] bytes = new byte[TOKEN_BYTES];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String limit(String value, int maxLength) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.magistr.app.config.tenant;
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -43,7 +41,7 @@ public class ConfigMapUpdater {
|
|||||||
public ConfigMapUpdater() {
|
public ConfigMapUpdater() {
|
||||||
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||||
if (!runningInK8s) {
|
if (!runningInK8s) {
|
||||||
log.info("Not running in K8s — ConfigMap updates will be skipped");
|
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +51,7 @@ public class ConfigMapUpdater {
|
|||||||
*/
|
*/
|
||||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||||
if (!runningInK8s) {
|
if (!runningInK8s) {
|
||||||
log.warn("Not in K8s, skipping ConfigMap update");
|
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +85,15 @@ public class ConfigMapUpdater {
|
|||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
|
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error updating ConfigMap: {}", e.getMessage());
|
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,7 @@ public class ConfigMapUpdater {
|
|||||||
.sslContext(sslContext)
|
.sslContext(sslContext)
|
||||||
.build();
|
.build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to create insecure client, using default: {}", e.getMessage());
|
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
|
||||||
return HttpClient.newHttpClient();
|
return HttpClient.newHttpClient();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.Connection;
|
import java.security.MessageDigest;
|
||||||
import java.sql.ResultSet;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.sql.Statement;
|
import java.util.HexFormat;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -58,20 +55,20 @@ public class TenantConfigWatcher {
|
|||||||
if (!file.exists()) return;
|
if (!file.exists()) return;
|
||||||
|
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
String hash = Integer.toHexString(content.hashCode());
|
String hash = configHash(content);
|
||||||
|
|
||||||
if (hash.equals(lastConfigHash)) {
|
if (hash.equals(lastConfigHash)) {
|
||||||
return; // Ничего не изменилось
|
return; // Ничего не изменилось
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
|
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
||||||
lastConfigHash = hash;
|
lastConfigHash = hash;
|
||||||
|
|
||||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||||
syncTenants(newTenants);
|
syncTenants(newTenants);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error watching tenants config: {}", e.getMessage());
|
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +80,19 @@ public class TenantConfigWatcher {
|
|||||||
File file = new File(tenantsConfigPath);
|
File file = new File(tenantsConfigPath);
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
lastConfigHash = Integer.toHexString(content.hashCode());
|
lastConfigHash = configHash(content);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to refresh config hash: {}", e.getMessage());
|
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String configHash(String content) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +109,7 @@ public class TenantConfigWatcher {
|
|||||||
for (TenantConfig tenant : newTenants) {
|
for (TenantConfig tenant : newTenants) {
|
||||||
String domain = tenant.getDomain().toLowerCase();
|
String domain = tenant.getDomain().toLowerCase();
|
||||||
if (!current.containsKey(domain)) {
|
if (!current.containsKey(domain)) {
|
||||||
log.info("Adding new tenant '{}' from ConfigMap update", domain);
|
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
|
||||||
routingDataSource.addTenant(tenant);
|
routingDataSource.addTenant(tenant);
|
||||||
// Инициализируем БД для нового тенанта
|
// Инициализируем БД для нового тенанта
|
||||||
initDatabaseForTenant(tenant);
|
initDatabaseForTenant(tenant);
|
||||||
@@ -113,7 +119,7 @@ public class TenantConfigWatcher {
|
|||||||
// Удалить тенанты, которых больше нет в конфиге
|
// Удалить тенанты, которых больше нет в конфиге
|
||||||
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||||
if (!newDomains.contains(existingDomain)) {
|
if (!newDomains.contains(existingDomain)) {
|
||||||
log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain);
|
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
|
||||||
routingDataSource.removeTenant(existingDomain);
|
routingDataSource.removeTenant(existingDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,7 @@ public class TenantConfigWatcher {
|
|||||||
try {
|
try {
|
||||||
TenantContext.setCurrentTenant(domain);
|
TenantContext.setCurrentTenant(domain);
|
||||||
|
|
||||||
log.info("[{}] Starting Flyway migrations...", domain);
|
log.info("[{}] Запускаем миграции Flyway", domain);
|
||||||
|
|
||||||
// Получаем DataSource конкретно для этого тенанта
|
// Получаем DataSource конкретно для этого тенанта
|
||||||
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||||
@@ -145,10 +151,10 @@ public class TenantConfigWatcher {
|
|||||||
.load();
|
.load();
|
||||||
|
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
log.info("[{}] Flyway migrations completed successfully", domain);
|
log.info("[{}] Миграции Flyway успешно выполнены", domain);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
TenantContext.clear();
|
TenantContext.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.magistr.app.config.tenant;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -13,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager;
|
|||||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
import jakarta.persistence.EntityManagerFactory;
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
@@ -30,7 +27,7 @@ import java.util.*;
|
|||||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class TenantDataSourceConfig implements WebMvcConfigurer {
|
public class TenantDataSourceConfig {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
try {
|
try {
|
||||||
routingDataSource.addTenant(tenant);
|
routingDataSource.addTenant(tenant);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
|
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,27 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
return new JpaTransactionManager(emf);
|
return new JpaTransactionManager(emf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@org.springframework.context.annotation.Lazy
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
|
||||||
private TenantRoutingDataSource tenantRoutingDataSource;
|
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
|
||||||
private AuthorizationInterceptor authorizationInterceptor;
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
|
|
||||||
TenantInterceptor interceptor = new TenantInterceptor();
|
|
||||||
interceptor.setRoutingDataSource(routingDataSource);
|
|
||||||
return interceptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
|
||||||
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
|
|
||||||
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
|
|
||||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TenantConfig> loadTenantsFromFile() {
|
private List<TenantConfig> loadTenantsFromFile() {
|
||||||
File file = new File(tenantsConfigPath);
|
File file = new File(tenantsConfigPath);
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||||
return list;
|
return list;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to read tenants config: {}", e.getMessage());
|
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||||
|
private final AuthorizationInterceptor authorizationInterceptor;
|
||||||
|
|
||||||
|
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
|
||||||
|
AuthorizationInterceptor authorizationInterceptor) {
|
||||||
|
this.tenantRoutingDataSource = tenantRoutingDataSource;
|
||||||
|
this.authorizationInterceptor = authorizationInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantInterceptor tenantInterceptor() {
|
||||||
|
TenantInterceptor interceptor = new TenantInterceptor();
|
||||||
|
interceptor.setRoutingDataSource(tenantRoutingDataSource);
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||||
|
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.magistr.app.controller;
|
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.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.LoginRequest;
|
||||||
import com.magistr.app.dto.LoginResponse;
|
import com.magistr.app.dto.LoginResponse;
|
||||||
import com.magistr.app.config.auth.AuthSessionService;
|
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.UserRepository;
|
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.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -19,7 +29,9 @@ public class AuthController {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
private final AuthSessionService sessionService;
|
private final JwtTokenService jwtTokenService;
|
||||||
|
private final RefreshTokenService refreshTokenService;
|
||||||
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
||||||
"ADMIN", "/admin/",
|
"ADMIN", "/admin/",
|
||||||
@@ -32,14 +44,18 @@ public class AuthController {
|
|||||||
|
|
||||||
public AuthController(UserRepository userRepository,
|
public AuthController(UserRepository userRepository,
|
||||||
BCryptPasswordEncoder passwordEncoder,
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
AuthSessionService sessionService) {
|
JwtTokenService jwtTokenService,
|
||||||
|
RefreshTokenService refreshTokenService,
|
||||||
|
JwtProperties jwtProperties) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.sessionService = sessionService;
|
this.jwtTokenService = jwtTokenService;
|
||||||
|
this.refreshTokenService = refreshTokenService;
|
||||||
|
this.jwtProperties = jwtProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||||
|
|
||||||
if (userOpt.isEmpty() ||
|
if (userOpt.isEmpty() ||
|
||||||
@@ -55,12 +71,52 @@ public class AuthController {
|
|||||||
.status(401)
|
.status(401)
|
||||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||||
}
|
}
|
||||||
String token = sessionService.createSession(user);
|
String tenant = TenantContext.getCurrentTenant();
|
||||||
String roleName = user.getRole().name();
|
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
String refreshToken = refreshTokenService.createSession(
|
||||||
Long departmentId = user.getDepartmentId();
|
user,
|
||||||
|
tenant,
|
||||||
|
servletRequest.getHeader("User-Agent"),
|
||||||
|
clientIp(servletRequest)
|
||||||
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
|
||||||
|
.body(loginResponse(user, accessToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<LoginResponse> refresh(HttpServletRequest request) {
|
||||||
|
Optional<String> refreshToken = refreshCookieValue(request);
|
||||||
|
if (refreshToken.isEmpty()) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RefreshTokenRotation> rotation = refreshTokenService.rotate(
|
||||||
|
refreshToken.get(),
|
||||||
|
TenantContext.getCurrentTenant(),
|
||||||
|
request.getHeader("User-Agent"),
|
||||||
|
clientIp(request)
|
||||||
|
);
|
||||||
|
if (rotation.isEmpty()) {
|
||||||
|
return unauthorizedWithClearedCookie();
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = rotation.get().user();
|
||||||
|
String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant());
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString())
|
||||||
|
.body(loginResponse(user, accessToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
|
||||||
|
refreshCookieValue(request).ifPresent(token ->
|
||||||
|
refreshTokenService.revoke(token, TenantContext.getCurrentTenant())
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
|
||||||
|
.body(Map.of("success", true, "message", "Выход выполнен"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@@ -69,11 +125,78 @@ public class AuthController {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(Map.of(
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
"userId", user.id(),
|
response.put("userId", user.id());
|
||||||
"username", user.username(),
|
response.put("username", user.username());
|
||||||
"role", user.role().name(),
|
response.put("role", user.role().name());
|
||||||
"departmentId", user.departmentId()
|
response.put("departmentId", user.departmentId());
|
||||||
));
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginResponse loginResponse(User user, String token) {
|
||||||
|
String roleName = user.getRole().name();
|
||||||
|
return new LoginResponse(
|
||||||
|
true,
|
||||||
|
"OK",
|
||||||
|
token,
|
||||||
|
roleName,
|
||||||
|
ROLE_REDIRECTS.getOrDefault(roleName, "/"),
|
||||||
|
user.getDepartmentId(),
|
||||||
|
user.getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<LoginResponse> unauthorized() {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(401)
|
||||||
|
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<LoginResponse> unauthorizedWithClearedCookie() {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(401)
|
||||||
|
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
|
||||||
|
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseCookie refreshCookie(String token) {
|
||||||
|
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token)
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(jwtProperties.isRefreshCookieSecure())
|
||||||
|
.sameSite("Lax")
|
||||||
|
.path("/api/auth")
|
||||||
|
.maxAge(jwtProperties.getRefreshTtl())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseCookie clearRefreshCookie() {
|
||||||
|
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(jwtProperties.isRefreshCookieSecure())
|
||||||
|
.sameSite("Lax")
|
||||||
|
.path("/api/auth")
|
||||||
|
.maxAge(Duration.ZERO)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> refreshCookieValue(HttpServletRequest request) {
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) {
|
||||||
|
return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/department")
|
@RequestMapping("/api/department")
|
||||||
@@ -148,6 +149,6 @@ public class DepartmentWorkspaceController {
|
|||||||
var user = AuthContext.getCurrentUser();
|
var user = AuthContext.getCurrentUser();
|
||||||
return user == null
|
return user == null
|
||||||
|| user.role() == Role.ADMIN
|
|| user.role() == Role.ADMIN
|
||||||
|| (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId()));
|
|| (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
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.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||||
|
? "Некорректный запрос"
|
||||||
|
: exception.getMessage();
|
||||||
|
return error(HttpStatus.BAD_REQUEST, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoSuchElementException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||||
|
? "Запрошенная запись не найдена"
|
||||||
|
: exception.getMessage();
|
||||||
|
return error(HttpStatus.NOT_FOUND, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({
|
||||||
|
HttpMessageNotReadableException.class,
|
||||||
|
MethodArgumentTypeMismatchException.class
|
||||||
|
})
|
||||||
|
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
|
||||||
|
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||||
|
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("status", status.value());
|
||||||
|
body.put("error", reason(status));
|
||||||
|
body.put("message", message);
|
||||||
|
body.put("path", request.getRequestURI());
|
||||||
|
return ResponseEntity.status(status).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String reason(HttpStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
case BAD_REQUEST -> "Некорректный запрос";
|
||||||
|
case NOT_FOUND -> "Не найдено";
|
||||||
|
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||||
|
default -> status.name();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,15 @@ import com.magistr.app.dto.GroupResponse;
|
|||||||
import com.magistr.app.model.AcademicCalendar;
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
import com.magistr.app.model.AcademicYear;
|
import com.magistr.app.model.AcademicYear;
|
||||||
import com.magistr.app.model.EducationForm;
|
import com.magistr.app.model.EducationForm;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Speciality;
|
import com.magistr.app.model.Speciality;
|
||||||
import com.magistr.app.model.SpecialtyProfile;
|
import com.magistr.app.model.SpecialtyProfile;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
import com.magistr.app.repository.*;
|
import com.magistr.app.repository.*;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
|
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -22,6 +23,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -41,6 +43,7 @@ public class GroupController {
|
|||||||
private final AcademicCalendarRepository academicCalendarRepository;
|
private final AcademicCalendarRepository academicCalendarRepository;
|
||||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
private final StudentGroupLifecycleService groupLifecycleService;
|
||||||
|
|
||||||
public GroupController(GroupRepository groupRepository,
|
public GroupController(GroupRepository groupRepository,
|
||||||
EducationFormRepository educationFormRepository,
|
EducationFormRepository educationFormRepository,
|
||||||
@@ -49,7 +52,8 @@ public class GroupController {
|
|||||||
AcademicYearRepository academicYearRepository,
|
AcademicYearRepository academicYearRepository,
|
||||||
AcademicCalendarRepository academicCalendarRepository,
|
AcademicCalendarRepository academicCalendarRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService) {
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
|
StudentGroupLifecycleService groupLifecycleService) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
this.specialtiesRepository = specialtiesRepository;
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
@@ -58,6 +62,7 @@ public class GroupController {
|
|||||||
this.academicCalendarRepository = academicCalendarRepository;
|
this.academicCalendarRepository = academicCalendarRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
|
this.groupLifecycleService = groupLifecycleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -67,7 +72,9 @@ public class GroupController {
|
|||||||
try {
|
try {
|
||||||
List<StudentGroup> groups = includeArchived
|
List<StudentGroup> groups = includeArchived
|
||||||
? groupRepository.findAll()
|
? groupRepository.findAll()
|
||||||
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
: groupRepository.findAll().stream()
|
||||||
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
List<GroupResponse> response = groups.stream()
|
List<GroupResponse> response = groups.stream()
|
||||||
.map(this::mapToResponse)
|
.map(this::mapToResponse)
|
||||||
@@ -84,7 +91,9 @@ public class GroupController {
|
|||||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||||
try {
|
try {
|
||||||
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||||
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
if(groups.isEmpty()) {
|
if(groups.isEmpty()) {
|
||||||
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
||||||
@@ -264,6 +273,8 @@ public class GroupController {
|
|||||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||||
Speciality speciality = group.getSpeciality();
|
Speciality speciality = group.getSpeciality();
|
||||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||||
return new GroupResponse(
|
return new GroupResponse(
|
||||||
group.getId(),
|
group.getId(),
|
||||||
group.getName(),
|
group.getName(),
|
||||||
@@ -278,7 +289,11 @@ public class GroupController {
|
|||||||
speciality.getSpecialityCode(),
|
speciality.getSpecialityCode(),
|
||||||
speciality.getSpecialityName(),
|
speciality.getSpecialityName(),
|
||||||
profile.getId(),
|
profile.getId(),
|
||||||
profile.getName()
|
profile.getName(),
|
||||||
|
group.getStatus(),
|
||||||
|
groupLifecycleService.isAvailableForSelection(group, today),
|
||||||
|
studyState.name(),
|
||||||
|
studyState.getLabel()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -42,27 +41,17 @@ public class ScheduleController {
|
|||||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return ResponseEntity.ok(scheduleQueryService.search(
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
groupId,
|
||||||
groupId,
|
teacherId,
|
||||||
teacherId,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
startDate,
|
||||||
startDate,
|
endDate
|
||||||
endDate
|
));
|
||||||
);
|
|
||||||
return ResponseEntity.ok(lessons);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("message", "Произошла ошибка при генерации расписания"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/schedule/search")
|
@RequestMapping("/api/schedule/search")
|
||||||
@@ -40,24 +39,17 @@ public class ScheduleSearchController {
|
|||||||
) {
|
) {
|
||||||
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
|
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
|
||||||
groupId, teacherId, classroomId, departmentId, startDate, endDate);
|
groupId, teacherId, classroomId, departmentId, startDate, endDate);
|
||||||
try {
|
return ResponseEntity.ok(scheduleQueryService.search(
|
||||||
return ResponseEntity.ok(scheduleQueryService.search(
|
groupId,
|
||||||
groupId,
|
teacherId,
|
||||||
teacherId,
|
classroomId,
|
||||||
classroomId,
|
departmentId,
|
||||||
departmentId,
|
subjectId,
|
||||||
subjectId,
|
lessonTypeId,
|
||||||
lessonTypeId,
|
timeSlotId,
|
||||||
timeSlotId,
|
parity,
|
||||||
parity,
|
startDate,
|
||||||
startDate,
|
endDate
|
||||||
endDate
|
));
|
||||||
));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -52,182 +51,115 @@ public class UserController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
logger.info("Получен запрос на получение всех пользователей");
|
logger.info("Получен запрос на получение всех пользователей");
|
||||||
try {
|
List<User> users = includeArchived
|
||||||
List<User> users = includeArchived
|
? userRepository.findAll()
|
||||||
? userRepository.findAll()
|
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
List<UserResponse> response = users.stream()
|
.toList();
|
||||||
.map(u -> {
|
logger.info("Получено {} пользователей", response.size());
|
||||||
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
return response;
|
||||||
.map(Department::getDepartmentName)
|
|
||||||
.orElse("Неизвестно");
|
|
||||||
|
|
||||||
UserResponse userResponse = new UserResponse(
|
|
||||||
u.getId(),
|
|
||||||
u.getUsername(),
|
|
||||||
u.getRole().name(),
|
|
||||||
u.getFullName(),
|
|
||||||
u.getJobTitle(),
|
|
||||||
departmentName);
|
|
||||||
userResponse.setStatus(u.getStatus());
|
|
||||||
return userResponse;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
logger.info("Получено {} пользователей", response.size());
|
|
||||||
return response;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||||
|
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||||
try {
|
.map(this::toUserResponse)
|
||||||
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
|
.toList();
|
||||||
|
logger.info("Получено {} преподавателей", response.size());
|
||||||
List<UserResponse> response = users.stream()
|
return response;
|
||||||
.map(u -> {
|
|
||||||
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
|
||||||
.map(Department::getDepartmentName)
|
|
||||||
.orElse("Неизвестно");
|
|
||||||
|
|
||||||
UserResponse userResponse = new UserResponse(
|
|
||||||
u.getId(),
|
|
||||||
u.getUsername(),
|
|
||||||
u.getRole().name(),
|
|
||||||
u.getFullName(),
|
|
||||||
u.getJobTitle(),
|
|
||||||
departmentName);
|
|
||||||
userResponse.setStatus(u.getStatus());
|
|
||||||
return userResponse;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
logger.info("Получено {} преподавателей", response.size());
|
|
||||||
return response;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers/{departmentId}")
|
@GetMapping("/teachers/{departmentId}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||||
try {
|
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||||
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
Role.TEACHER,
|
||||||
Role.TEACHER,
|
departmentId,
|
||||||
departmentId,
|
LifecycleEntity.STATUS_ARCHIVED
|
||||||
LifecycleEntity.STATUS_ARCHIVED
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body("Преподаватели для указанной кафедры не найдены");
|
.body("Преподаватели для указанной кафедры не найдены");
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
|
||||||
|
|
||||||
List<UserResponse> userResponses = users.stream()
|
|
||||||
.map( user -> {
|
|
||||||
|
|
||||||
return new UserResponse(
|
|
||||||
user.getId(),
|
|
||||||
user.getRole().name(),
|
|
||||||
user.getFullName(),
|
|
||||||
user.getJobTitle(),
|
|
||||||
user.getDepartmentId()
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return ResponseEntity.ok(userResponses);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
.body("Произошла ошибка при получении списка преподавателей");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||||
|
return ResponseEntity.ok(users.stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||||
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||||
|
|
||||||
try {
|
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
String errorMessage = "Имя пользователя обязательно";
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
|
||||||
String errorMessage = "Пароль минимум 4 символа";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
|
||||||
String errorMessage = "Пользователь уже существует";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
|
||||||
String errorMessage = "Имя пользователя обязательно";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
|
||||||
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
|
||||||
request.setJobTitle("Не указано");
|
|
||||||
}
|
|
||||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
|
||||||
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
Role role;
|
|
||||||
try {
|
|
||||||
role = Role.valueOf(request.getRole());
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
|
||||||
}
|
|
||||||
|
|
||||||
User user = new User();
|
|
||||||
user.setUsername(request.getUsername());
|
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
|
||||||
user.setRole(role);
|
|
||||||
user.setFullName(request.getFullName());
|
|
||||||
user.setJobTitle(request.getJobTitle());
|
|
||||||
user.setDepartmentId(request.getDepartmentId());
|
|
||||||
userRepository.save(user);
|
|
||||||
if (role == Role.TEACHER) {
|
|
||||||
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
|
||||||
if (department != null) {
|
|
||||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
|
||||||
assignment.setTeacher(user);
|
|
||||||
assignment.setDepartment(department);
|
|
||||||
assignment.setValidFrom(LocalDate.now());
|
|
||||||
assignment.setPrimaryAssignment(true);
|
|
||||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
|
||||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
|
||||||
teacherDepartmentAssignmentRepository.save(assignment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
|
||||||
} catch (Exception e ) {
|
|
||||||
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
||||||
|
String errorMessage = "Пароль минимум 4 символа";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||||
|
String errorMessage = "Пользователь уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
||||||
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
||||||
|
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
||||||
|
request.setJobTitle("Не указано");
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Role role;
|
||||||
|
try {
|
||||||
|
role = Role.valueOf(request.getRole());
|
||||||
|
} catch (IllegalArgumentException | NullPointerException e) {
|
||||||
|
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(request.getUsername());
|
||||||
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setRole(role);
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setJobTitle(request.getJobTitle());
|
||||||
|
user.setDepartmentId(request.getDepartmentId());
|
||||||
|
userRepository.save(user);
|
||||||
|
if (role == Role.TEACHER) {
|
||||||
|
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
||||||
|
if (department != null) {
|
||||||
|
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||||
|
assignment.setTeacher(user);
|
||||||
|
assignment.setDepartment(department);
|
||||||
|
assignment.setValidFrom(LocalDate.now());
|
||||||
|
assignment.setPrimaryAssignment(true);
|
||||||
|
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||||
|
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||||
|
teacherDepartmentAssignmentRepository.save(assignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||||
|
return ResponseEntity.ok(toUserResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -252,8 +184,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
user.restore();
|
user.restore();
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
return ResponseEntity.ok(toUserResponse(user));
|
||||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/department-history")
|
@GetMapping("/{id}/department-history")
|
||||||
@@ -322,11 +253,29 @@ public class UserController {
|
|||||||
.stream()
|
.stream()
|
||||||
.map(TeacherDepartmentAssignment::getTeacher)
|
.map(TeacherDepartmentAssignment::getTeacher)
|
||||||
.filter(User::isActiveRecord)
|
.filter(User::isActiveRecord)
|
||||||
.map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
.map(this::toUserResponse)
|
||||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
|
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UserResponse toUserResponse(User user) {
|
||||||
|
String departmentName = user.getDepartmentId() == null
|
||||||
|
? null
|
||||||
|
: departmentRepository.findById(user.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
UserResponse response = new UserResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getRole().name(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getJobTitle(),
|
||||||
|
departmentName
|
||||||
|
);
|
||||||
|
response.setDepartmentId(user.getDepartmentId());
|
||||||
|
response.setStatus(user.getStatus());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
||||||
return new TeacherDepartmentAssignmentDto(
|
return new TeacherDepartmentAssignmentDto(
|
||||||
assignment.getId(),
|
assignment.getId(),
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ public class WorkloadController {
|
|||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||||
.filter(Classroom::getIsAvailable)
|
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
|
||||||
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
|
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
|
||||||
.map(this::toClassroomResponse)
|
.map(this::toClassroomResponse)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public class GroupResponse {
|
|||||||
private String specialtyName;
|
private String specialtyName;
|
||||||
private Long specialtyProfileId;
|
private Long specialtyProfileId;
|
||||||
private String specialtyProfileName;
|
private String specialtyProfileName;
|
||||||
|
private String status;
|
||||||
|
private Boolean active;
|
||||||
|
private String studyState;
|
||||||
|
private String studyStateName;
|
||||||
|
|
||||||
public GroupResponse(Long id,
|
public GroupResponse(Long id,
|
||||||
String name,
|
String name,
|
||||||
@@ -33,7 +37,11 @@ public class GroupResponse {
|
|||||||
String specialtyCode,
|
String specialtyCode,
|
||||||
String specialtyName,
|
String specialtyName,
|
||||||
Long specialtyProfileId,
|
Long specialtyProfileId,
|
||||||
String specialtyProfileName) {
|
String specialtyProfileName,
|
||||||
|
String status,
|
||||||
|
Boolean active,
|
||||||
|
String studyState,
|
||||||
|
String studyStateName) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.groupSize = groupSize;
|
this.groupSize = groupSize;
|
||||||
@@ -48,6 +56,10 @@ public class GroupResponse {
|
|||||||
this.specialtyName = specialtyName;
|
this.specialtyName = specialtyName;
|
||||||
this.specialtyProfileId = specialtyProfileId;
|
this.specialtyProfileId = specialtyProfileId;
|
||||||
this.specialtyProfileName = specialtyProfileName;
|
this.specialtyProfileName = specialtyProfileName;
|
||||||
|
this.status = status;
|
||||||
|
this.active = active;
|
||||||
|
this.studyState = studyState;
|
||||||
|
this.studyStateName = studyStateName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -109,4 +121,20 @@ public class GroupResponse {
|
|||||||
public String getSpecialtyProfileName() {
|
public String getSpecialtyProfileName() {
|
||||||
return specialtyProfileName;
|
return specialtyProfileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getActive() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStudyState() {
|
||||||
|
return studyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStudyStateName() {
|
||||||
|
return studyStateName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ public class UserResponse {
|
|||||||
return departmentId;
|
return departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "auth_refresh_tokens")
|
||||||
|
public class AuthRefreshToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String tenant;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "issued_at", nullable = false)
|
||||||
|
private LocalDateTime issuedAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private LocalDateTime revokedAt;
|
||||||
|
|
||||||
|
@Column(name = "rotated_to_token_hash", length = 64)
|
||||||
|
private String rotatedToTokenHash;
|
||||||
|
|
||||||
|
@Column(name = "user_agent", length = 512)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Column(name = "ip_address", length = 64)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTenant() {
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenant(String tenant) {
|
||||||
|
this.tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTokenHash() {
|
||||||
|
return tokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenHash(String tokenHash) {
|
||||||
|
this.tokenHash = tokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getIssuedAt() {
|
||||||
|
return issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||||
|
this.issuedAt = issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getRevokedAt() {
|
||||||
|
return revokedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||||
|
this.revokedAt = revokedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRotatedToTokenHash() {
|
||||||
|
return rotatedToTokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRotatedToTokenHash(String rotatedToTokenHash) {
|
||||||
|
this.rotatedToTokenHash = rotatedToTokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive(LocalDateTime now) {
|
||||||
|
return revokedAt == null && expiresAt.isAfter(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,9 @@ public abstract class LifecycleEntity {
|
|||||||
if (date == null) {
|
if (date == null) {
|
||||||
return isActiveRecord();
|
return isActiveRecord();
|
||||||
}
|
}
|
||||||
|
if (isArchivedRecord() && activeTo == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
||||||
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
|
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
|
||||||
return afterStart && beforeEnd;
|
return afterStart && beforeEnd;
|
||||||
|
|||||||
@@ -21,16 +21,35 @@ public enum ScheduleLessonCategory {
|
|||||||
if (lessonType == null || lessonType.getLessonType() == null) {
|
if (lessonType == null || lessonType.getLessonType() == null) {
|
||||||
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
}
|
}
|
||||||
String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU"));
|
String name = lessonType.getLessonType()
|
||||||
if (name.contains("лекц")) {
|
.trim()
|
||||||
|
.toLowerCase(Locale.forLanguageTag("ru-RU"))
|
||||||
|
.replace('ё', 'е');
|
||||||
|
boolean lecture = name.equals("лекция") || name.equals("лекции") || name.startsWith("лекц");
|
||||||
|
boolean laboratory = name.equals("лабораторная работа")
|
||||||
|
|| name.equals("лабораторные работы")
|
||||||
|
|| name.startsWith("лаб")
|
||||||
|
|| name.contains("лаборатор");
|
||||||
|
boolean practice = name.equals("практика")
|
||||||
|
|| name.equals("практики")
|
||||||
|
|| name.startsWith("практ")
|
||||||
|
|| name.contains("практичес")
|
||||||
|
|| name.contains("семинар");
|
||||||
|
int matches = (lecture ? 1 : 0) + (laboratory ? 1 : 0) + (practice ? 1 : 0);
|
||||||
|
if (matches > 1) {
|
||||||
|
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
|
||||||
|
+ "\" неоднозначен. Используйте отдельный тип: лекция, практика или лабораторная работа");
|
||||||
|
}
|
||||||
|
if (lecture) {
|
||||||
return LECTURE;
|
return LECTURE;
|
||||||
}
|
}
|
||||||
if (name.contains("лаб")) {
|
if (laboratory) {
|
||||||
return LABORATORY;
|
return LABORATORY;
|
||||||
}
|
}
|
||||||
if (name.contains("практ")) {
|
if (practice) {
|
||||||
return PRACTICE;
|
return PRACTICE;
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
|
||||||
|
+ "\" должен быть лекцией, практикой или лабораторной работой");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.magistr.app.model;
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -89,7 +90,7 @@ public class ScheduleRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(String status) {
|
public void setStatus(String status) {
|
||||||
this.status = status;
|
this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getValidFrom() {
|
public LocalDate getValidFrom() {
|
||||||
@@ -172,6 +173,38 @@ public class ScheduleRule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveRecord() {
|
||||||
|
return !LifecycleEntity.STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isArchivedRecord() {
|
||||||
|
return LifecycleEntity.STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveOn(LocalDate date) {
|
||||||
|
if (date == null) {
|
||||||
|
return isActiveRecord();
|
||||||
|
}
|
||||||
|
if (!isActiveRecord()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean afterStart = validFrom == null || !date.isBefore(validFrom);
|
||||||
|
boolean beforeEnd = validTo == null || !date.isAfter(validTo);
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void archive() {
|
||||||
|
status = LifecycleEntity.STATUS_ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void restore() {
|
||||||
|
status = LifecycleEntity.STATUS_ACTIVE;
|
||||||
|
validTo = null;
|
||||||
|
}
|
||||||
|
|
||||||
private int valueOrZero(Integer value) {
|
private int valueOrZero(Integer value) {
|
||||||
return valueOrDefault(value, 0);
|
return valueOrDefault(value, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
public enum StudentGroupStudyState {
|
||||||
|
ACTIVE("Активна"),
|
||||||
|
NOT_STARTED("Обучение не началось"),
|
||||||
|
GRADUATED("Завершила обучение"),
|
||||||
|
INACTIVE("Неактивна"),
|
||||||
|
ARCHIVED("Архив");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
StudentGroupStudyState(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.AuthRefreshToken;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
||||||
|
|
||||||
|
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import java.time.DayOfWeek;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.TextStyle;
|
import java.time.format.TextStyle;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -34,7 +33,6 @@ public class ScheduleGeneratorService {
|
|||||||
private final TimeSlotRepository timeSlotRepository;
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||||
|
|
||||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||||
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||||
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||||
@@ -81,9 +81,12 @@ public class ScheduleGeneratorService {
|
|||||||
semester.getId(),
|
semester.getId(),
|
||||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
||||||
);
|
);
|
||||||
|
if (primedSemesterIds.add(semester.getId())) {
|
||||||
|
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
|
||||||
|
}
|
||||||
|
|
||||||
for (ScheduleRule rule : sortRules(rules)) {
|
for (ScheduleRule rule : sortRules(rules)) {
|
||||||
renderRuleForDate(rule, date, group, null, result);
|
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +100,9 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||||
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||||
@@ -110,9 +115,12 @@ public class ScheduleGeneratorService {
|
|||||||
semester.getId(),
|
semester.getId(),
|
||||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
||||||
);
|
);
|
||||||
|
if (primedSemesterIds.add(semester.getId())) {
|
||||||
|
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
|
||||||
|
}
|
||||||
|
|
||||||
for (ScheduleRule rule : sortRules(rules)) {
|
for (ScheduleRule rule : sortRules(rules)) {
|
||||||
renderRuleForDate(rule, date, null, teacherId, result);
|
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +128,8 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void clearCache() {
|
public void clearCache() {
|
||||||
consumedHoursCache.clear();
|
// Расход часов считается в контексте одного построения расписания.
|
||||||
|
// Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных.
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||||
@@ -152,10 +161,34 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderRuleForDate(ScheduleRule rule,
|
private void primeConsumedHoursBeforeRange(Semester semester,
|
||||||
|
List<ScheduleRule> rules,
|
||||||
|
LocalDate rangeStart,
|
||||||
|
StudentGroup targetGroup,
|
||||||
|
Long targetTeacherId,
|
||||||
|
Map<String, Integer> consumedHoursProgress) {
|
||||||
|
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||||
|
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate minDate(LocalDate first, LocalDate second) {
|
||||||
|
return first.isBefore(second) ? first : second;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processRuleForDate(ScheduleRule rule,
|
||||||
LocalDate date,
|
LocalDate date,
|
||||||
StudentGroup targetGroup,
|
StudentGroup targetGroup,
|
||||||
Long targetTeacherId,
|
Long targetTeacherId,
|
||||||
|
Map<String, Integer> consumedHoursProgress,
|
||||||
List<RenderedLessonDto> result) {
|
List<RenderedLessonDto> result) {
|
||||||
if (rule.getLectureAcademicHours() == null
|
if (rule.getLectureAcademicHours() == null
|
||||||
|| rule.getLaboratoryAcademicHours() == null
|
|| rule.getLaboratoryAcademicHours() == null
|
||||||
@@ -170,6 +203,9 @@ public class ScheduleGeneratorService {
|
|||||||
if (!rule.getSubject().isActiveOn(date)) {
|
if (!rule.getSubject().isActiveOn(date)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!rule.isActiveOn(date)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Semester semester = semesterOpt.get();
|
Semester semester = semesterOpt.get();
|
||||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||||
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
|
|
||||||
|
|
||||||
for (ScheduleRuleSlot slot : slots) {
|
for (ScheduleRuleSlot slot : slots) {
|
||||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -217,11 +251,8 @@ public class ScheduleGeneratorService {
|
|||||||
|
|
||||||
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
||||||
for (LessonScope scope : lessonScopes) {
|
for (LessonScope scope : lessonScopes) {
|
||||||
String consumptionBucket = consumptionBucket(category, scope.subgroupId());
|
String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId());
|
||||||
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0);
|
||||||
consumptionBucket,
|
|
||||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
|
|
||||||
);
|
|
||||||
if (consumedHours >= totalHours) {
|
if (consumedHours >= totalHours) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -233,28 +264,30 @@ public class ScheduleGeneratorService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
if (result != null) {
|
||||||
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
||||||
activeScopes.forEach(scope -> {
|
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
||||||
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
|
activeScopes.forEach(scope -> {
|
||||||
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
|
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
|
||||||
});
|
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
|
||||||
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
|
});
|
||||||
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
|
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
|
||||||
int consumedHours = activeScopes.stream()
|
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
|
||||||
.mapToInt(ActiveLessonScope::consumedHours)
|
int consumedHours = activeScopes.stream()
|
||||||
.min()
|
.mapToInt(ActiveLessonScope::consumedHours)
|
||||||
.orElse(0);
|
.min()
|
||||||
int remainingAfterLesson = activeScopes.stream()
|
.orElse(0);
|
||||||
.mapToInt(ActiveLessonScope::remainingAfterLesson)
|
int remainingAfterLesson = activeScopes.stream()
|
||||||
.min()
|
.mapToInt(ActiveLessonScope::remainingAfterLesson)
|
||||||
.orElse(0);
|
.min()
|
||||||
|
.orElse(0);
|
||||||
|
|
||||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||||
totalHours, consumedHours, remainingAfterLesson));
|
totalHours, consumedHours, remainingAfterLesson));
|
||||||
|
}
|
||||||
for (ActiveLessonScope scope : activeScopes) {
|
for (ActiveLessonScope scope : activeScopes) {
|
||||||
consumedHoursByBucket.put(
|
consumedHoursProgress.put(
|
||||||
consumptionBucket(category, scope.scope().subgroupId()),
|
consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()),
|
||||||
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
|
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,62 +308,20 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
|
|
||||||
StudentGroup targetGroup,
|
|
||||||
LocalDate targetDate,
|
|
||||||
ScheduleLessonCategory category,
|
|
||||||
Long subgroupId) {
|
|
||||||
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
|
||||||
String cacheKey = rule.getId()
|
|
||||||
+ ":" + category.name()
|
|
||||||
+ ":" + (subgroupId == null ? "whole" : subgroupId)
|
|
||||||
+ ":" + (groupId == null ? "all" : groupId)
|
|
||||||
+ ":" + targetDate;
|
|
||||||
return consumedHoursCache.computeIfAbsent(cacheKey,
|
|
||||||
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private int calculateConsumedHours(ScheduleRule rule,
|
|
||||||
StudentGroup targetGroup,
|
|
||||||
LocalDate targetDate,
|
|
||||||
ScheduleLessonCategory category,
|
|
||||||
Long subgroupId) {
|
|
||||||
int consumed = 0;
|
|
||||||
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
|
|
||||||
Semester semester = rule.getSemester();
|
|
||||||
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
|
||||||
if (weekNumber < rule.startWeekFor(category)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
|
||||||
if (eligibleGroups.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
|
||||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
|
||||||
long activeSlots = rule.getSlots().stream()
|
|
||||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
|
||||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
|
||||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
|
||||||
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
|
|
||||||
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
|
|
||||||
.count();
|
|
||||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
|
||||||
if (consumed >= rule.academicHoursFor(category)) {
|
|
||||||
return consumed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return consumed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
||||||
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String consumptionProgressKey(ScheduleRule rule,
|
||||||
|
StudentGroup targetGroup,
|
||||||
|
ScheduleLessonCategory category,
|
||||||
|
Long subgroupId) {
|
||||||
|
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
||||||
|
return rule.getId()
|
||||||
|
+ ":" + consumptionBucket(category, subgroupId)
|
||||||
|
+ ":" + (groupId == null ? "all" : groupId);
|
||||||
|
}
|
||||||
|
|
||||||
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||||
ScheduleLessonCategory category,
|
ScheduleLessonCategory category,
|
||||||
List<StudentGroup> eligibleGroups) {
|
List<StudentGroup> eligibleGroups) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.magistr.app.service;
|
package com.magistr.app.service;
|
||||||
|
|
||||||
import com.magistr.app.dto.RenderedLessonDto;
|
import com.magistr.app.dto.RenderedLessonDto;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
|
||||||
import com.magistr.app.model.ScheduleOverride;
|
import com.magistr.app.model.ScheduleOverride;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
import com.magistr.app.repository.GroupRepository;
|
||||||
@@ -9,22 +8,29 @@ import com.magistr.app.repository.ScheduleOverrideRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ScheduleQueryService {
|
public class ScheduleQueryService {
|
||||||
|
|
||||||
|
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
||||||
|
private static final long MAX_RANGE_DAYS = 120;
|
||||||
|
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||||
|
private final StudentGroupLifecycleService groupLifecycleService;
|
||||||
|
|
||||||
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
ScheduleOverrideRepository scheduleOverrideRepository) {
|
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||||
|
StudentGroupLifecycleService groupLifecycleService) {
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||||
|
this.groupLifecycleService = groupLifecycleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RenderedLessonDto> search(Long groupId,
|
public List<RenderedLessonDto> search(Long groupId,
|
||||||
@@ -37,10 +43,16 @@ public class ScheduleQueryService {
|
|||||||
String parity,
|
String parity,
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate) {
|
LocalDate endDate) {
|
||||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
validateRange(startDate, endDate);
|
||||||
List<RenderedLessonDto> generatedLessons = groups.stream()
|
List<RenderedLessonDto> generatedLessons;
|
||||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
if (groupId == null && departmentId == null && teacherId != null) {
|
||||||
.toList();
|
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<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
|
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
|
||||||
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
||||||
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
||||||
@@ -132,17 +144,39 @@ public class ScheduleQueryService {
|
|||||||
return scheduleRuleSlotId + ":" + date;
|
return scheduleRuleSlotId + ":" + date;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId) {
|
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||||
|
if (startDate == null || endDate == null) {
|
||||||
|
throw new IllegalArgumentException("Дата начала и дата окончания обязательны");
|
||||||
|
}
|
||||||
|
if (endDate.isBefore(startDate)) {
|
||||||
|
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||||
|
}
|
||||||
|
if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
|
||||||
|
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
|
||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
return groupRepository.findById(groupId)
|
return groupRepository.findById(groupId)
|
||||||
.filter(StudentGroup::isActiveRecord)
|
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||||
.map(List::of)
|
.map(List::of)
|
||||||
.orElse(List.of());
|
.orElse(List.of());
|
||||||
}
|
}
|
||||||
|
List<StudentGroup> groups;
|
||||||
if (departmentId != null) {
|
if (departmentId != null) {
|
||||||
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
groups = groupRepository.findByDepartmentId(departmentId);
|
||||||
|
} else {
|
||||||
|
groups = groupRepository.findAll();
|
||||||
}
|
}
|
||||||
return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
groups = groups.stream()
|
||||||
|
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||||
|
.toList();
|
||||||
|
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||||
|
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||||
|
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
|
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class StudentGroupLifecycleService {
|
||||||
|
|
||||||
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
|
|
||||||
|
public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) {
|
||||||
|
this.assignmentRepository = assignmentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailableForSelection(StudentGroup group, LocalDate date) {
|
||||||
|
return group != null
|
||||||
|
&& group.isActiveRecord()
|
||||||
|
&& group.isActiveOn(date)
|
||||||
|
&& !isGraduatedOn(group, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) {
|
||||||
|
if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<Integer> courseLimit = resolveCourseLimit(group);
|
||||||
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
|
if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) {
|
||||||
|
if (group == null || group.isArchivedRecord()) {
|
||||||
|
return StudentGroupStudyState.ARCHIVED;
|
||||||
|
}
|
||||||
|
if (!group.isActiveOn(date)) {
|
||||||
|
return StudentGroupStudyState.INACTIVE;
|
||||||
|
}
|
||||||
|
if (isGraduatedOn(group, date)) {
|
||||||
|
return StudentGroupStudyState.GRADUATED;
|
||||||
|
}
|
||||||
|
if (!hasStarted(group, date)) {
|
||||||
|
return StudentGroupStudyState.NOT_STARTED;
|
||||||
|
}
|
||||||
|
return StudentGroupStudyState.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasStarted(StudentGroup group, LocalDate date) {
|
||||||
|
return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGraduatedOn(StudentGroup group, LocalDate date) {
|
||||||
|
return isGraduatedOn(group, date, resolveCourseLimit(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional<Integer> courseLimit) {
|
||||||
|
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
|
||||||
|
return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Integer> resolveCourseLimit(StudentGroup group) {
|
||||||
|
if (group.getId() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream()
|
||||||
|
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||||
|
.filter(calendar -> calendar.getCourseCount() != null)
|
||||||
|
.map(calendar -> calendar.getCourseCount())
|
||||||
|
.max(Comparator.naturalOrder());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,22 +9,30 @@ import java.time.LocalDate;
|
|||||||
public class CourseAndSemesterCalculator {
|
public class CourseAndSemesterCalculator {
|
||||||
|
|
||||||
public static int getActualCourse(Integer yearStartStudy) {
|
public static int getActualCourse(Integer yearStartStudy) {
|
||||||
LocalDate now = LocalDate.now();
|
return getActualCourse(yearStartStudy, LocalDate.now());
|
||||||
int currentYear = now.getYear();
|
}
|
||||||
int currentMonth = now.getMonthValue();
|
|
||||||
|
|
||||||
if (currentMonth >= 9) {
|
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
|
||||||
return currentYear - yearStartStudy + 1;
|
int currentYear = date.getYear();
|
||||||
} else {
|
int currentMonth = date.getMonthValue();
|
||||||
return currentYear - yearStartStudy;
|
int course = currentMonth >= 9
|
||||||
}
|
? currentYear - yearStartStudy + 1
|
||||||
|
: currentYear - yearStartStudy;
|
||||||
|
return Math.max(0, course);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int getActualSemester(Integer yearStartStudy) {
|
public static int getActualSemester(Integer yearStartStudy) {
|
||||||
int course = getActualCourse(yearStartStudy);
|
return getActualSemester(yearStartStudy, LocalDate.now());
|
||||||
int currentMonth = LocalDate.now().getMonthValue();
|
}
|
||||||
|
|
||||||
if ( currentMonth <= 1 || currentMonth >= 9) {
|
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
|
||||||
|
int course = getActualCourse(yearStartStudy, date);
|
||||||
|
if (course == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int currentMonth = date.getMonthValue();
|
||||||
|
|
||||||
|
if (currentMonth <= 1 || currentMonth >= 9) {
|
||||||
return course * 2 - 1;
|
return course * 2 - 1;
|
||||||
} else {
|
} else {
|
||||||
return course * 2;
|
return course * 2;
|
||||||
@@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator {
|
|||||||
|
|
||||||
public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
|
public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
|
||||||
int recordYear = Integer.parseInt(periodYears.substring(0, 4));
|
int recordYear = Integer.parseInt(periodYears.substring(0, 4));
|
||||||
return recordYear - yearStartStudy + 1;
|
return Math.max(0, recordYear - yearStartStudy + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
|
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
|
||||||
int course = getFutureCourse(yearStartStudy, periodYears);
|
int course = getFutureCourse(yearStartStudy, periodYears);
|
||||||
|
if (course == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (semesterType == SemesterType.autumn) {
|
if (semesterType == SemesterType.autumn) {
|
||||||
return course * 2 - 1;
|
return course * 2 - 1;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ server.port=8080
|
|||||||
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
|
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
|
||||||
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
||||||
spring.datasource.username=${POSTGRES_USER:myuser}
|
spring.datasource.username=${POSTGRES_USER:myuser}
|
||||||
spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword}
|
spring.datasource.password=${POSTGRES_PASSWORD:}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# JPA
|
# JPA
|
||||||
@@ -14,5 +14,11 @@ spring.jpa.open-in-view=false
|
|||||||
# Мультитенантность
|
# Мультитенантность
|
||||||
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
||||||
|
|
||||||
#logging.level.root=DEBUG
|
# JWT авторизация
|
||||||
|
app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum}
|
||||||
|
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}
|
||||||
|
|
||||||
|
#logging.level.root=DEBUG
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Ад
|
|||||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant VARCHAR(100) NOT NULL,
|
||||||
|
token_hash VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
revoked_at TIMESTAMP,
|
||||||
|
rotated_to_token_hash VARCHAR(64),
|
||||||
|
user_agent VARCHAR(512),
|
||||||
|
ip_address VARCHAR(64)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user
|
||||||
|
ON auth_refresh_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires
|
||||||
|
ON auth_refresh_tokens(tenant, expires_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS teacher_department_assignments (
|
CREATE TABLE IF NOT EXISTS teacher_department_assignments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
@@ -950,6 +969,17 @@ COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
|
|||||||
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
|
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
|
||||||
COMMENT ON COLUMN users.department_id IS 'ID кафедры';
|
COMMENT ON COLUMN users.department_id IS 'ID кафедры';
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена';
|
||||||
|
COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена';
|
||||||
|
|
||||||
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
|
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
|
||||||
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
|
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
|
||||||
COMMENT ON COLUMN education_forms.description IS 'Описание';
|
COMMENT ON COLUMN education_forms.description IS 'Описание';
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
import com.nimbusds.jose.JOSEException;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.JWSHeader;
|
||||||
|
import com.nimbusds.jose.crypto.MACSigner;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class JwtTokenServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticatesValidTokenForSameTenant() {
|
||||||
|
JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
|
||||||
|
String token = service.createAccessToken(user(), "magistr");
|
||||||
|
|
||||||
|
var authenticated = service.authenticate(token, "magistr");
|
||||||
|
|
||||||
|
assertThat(authenticated).isPresent();
|
||||||
|
assertThat(authenticated.get().id()).isEqualTo(10L);
|
||||||
|
assertThat(authenticated.get().username()).isEqualTo("admin");
|
||||||
|
assertThat(authenticated.get().role()).isEqualTo(Role.ADMIN);
|
||||||
|
assertThat(authenticated.get().departmentId()).isEqualTo(2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenForDifferentTenant() {
|
||||||
|
JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
|
||||||
|
String token = service.createAccessToken(user(), "magistr");
|
||||||
|
|
||||||
|
assertThat(service.authenticate(token, "n8n")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenWithDifferentSignature() {
|
||||||
|
JwtTokenService issuer = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15)));
|
||||||
|
JwtTokenService verifier = new JwtTokenService(properties("abcdefghijklmnopqrstuvwxyz123456", Duration.ofMinutes(15)));
|
||||||
|
String token = issuer.createAccessToken(user(), "magistr");
|
||||||
|
|
||||||
|
assertThat(verifier.authenticate(token, "magistr")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsExpiredToken() {
|
||||||
|
String secret = "12345678901234567890123456789012";
|
||||||
|
JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15)));
|
||||||
|
String token = expiredToken(secret);
|
||||||
|
|
||||||
|
assertThat(service.authenticate(token, "magistr")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String expiredToken(String secret) {
|
||||||
|
try {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
|
.issuer("magistr")
|
||||||
|
.subject("10")
|
||||||
|
.jwtID("expired")
|
||||||
|
.issueTime(Date.from(now.minusSeconds(120)))
|
||||||
|
.expirationTime(Date.from(now.minusSeconds(90)))
|
||||||
|
.claim("tenant", "magistr")
|
||||||
|
.claim("userId", 10L)
|
||||||
|
.claim("username", "admin")
|
||||||
|
.claim("role", "ADMIN")
|
||||||
|
.claim("departmentId", 2L)
|
||||||
|
.build();
|
||||||
|
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
|
||||||
|
jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return jwt.serialize();
|
||||||
|
} catch (JOSEException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtProperties properties(String secret, Duration accessTtl) {
|
||||||
|
JwtProperties properties = new JwtProperties();
|
||||||
|
properties.setSecret(secret);
|
||||||
|
properties.setAccessTtl(accessTtl);
|
||||||
|
properties.setRefreshTtl(Duration.ofDays(7));
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(10L);
|
||||||
|
user.setUsername("admin");
|
||||||
|
user.setRole(Role.ADMIN);
|
||||||
|
user.setDepartmentId(2L);
|
||||||
|
user.setFullName("Администратор");
|
||||||
|
user.setJobTitle("Администратор");
|
||||||
|
user.setPassword("hash");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.AuthRefreshToken;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
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.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class RefreshTokenServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesOnlyTokenHash() {
|
||||||
|
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||||
|
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||||
|
|
||||||
|
String rawToken = service.createSession(user(), "magistr", "Browser", "127.0.0.1");
|
||||||
|
|
||||||
|
ArgumentCaptor<AuthRefreshToken> captor = ArgumentCaptor.forClass(AuthRefreshToken.class);
|
||||||
|
verify(repository).save(captor.capture());
|
||||||
|
AuthRefreshToken stored = captor.getValue();
|
||||||
|
|
||||||
|
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
|
||||||
|
assertThat(stored.getTokenHash()).hasSize(64);
|
||||||
|
assertThat(stored.getTenant()).isEqualTo("magistr");
|
||||||
|
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rotatesRefreshTokenAndRejectsReplay() {
|
||||||
|
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||||
|
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||||
|
String rawToken = "raw-refresh-token";
|
||||||
|
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
|
||||||
|
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
|
||||||
|
|
||||||
|
Optional<RefreshTokenRotation> rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
|
||||||
|
|
||||||
|
assertThat(rotation).isPresent();
|
||||||
|
assertThat(rotation.get().refreshToken()).isNotEqualTo(rawToken);
|
||||||
|
assertThat(stored.getRevokedAt()).isNotNull();
|
||||||
|
assertThat(stored.getRotatedToTokenHash()).hasSize(64);
|
||||||
|
|
||||||
|
Optional<RefreshTokenRotation> replay = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
|
||||||
|
assertThat(replay).isEmpty();
|
||||||
|
verify(repository, times(2)).save(any(AuthRefreshToken.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenFromDifferentTenant() {
|
||||||
|
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||||
|
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||||
|
String rawToken = "raw-refresh-token";
|
||||||
|
when(repository.findByTokenHash(service.hashToken(rawToken)))
|
||||||
|
.thenReturn(Optional.of(activeToken(service.hashToken(rawToken))));
|
||||||
|
|
||||||
|
assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutRevokesActiveToken() {
|
||||||
|
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||||
|
when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||||
|
String rawToken = "raw-refresh-token";
|
||||||
|
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
|
||||||
|
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
|
||||||
|
|
||||||
|
service.revoke(rawToken, "magistr");
|
||||||
|
|
||||||
|
assertThat(stored.getRevokedAt()).isNotNull();
|
||||||
|
verify(repository).save(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtProperties properties() {
|
||||||
|
JwtProperties properties = new JwtProperties();
|
||||||
|
properties.setRefreshTtl(Duration.ofDays(7));
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthRefreshToken activeToken(String hash) {
|
||||||
|
AuthRefreshToken token = new AuthRefreshToken();
|
||||||
|
token.setUser(user());
|
||||||
|
token.setTenant("magistr");
|
||||||
|
token.setTokenHash(hash);
|
||||||
|
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
|
||||||
|
token.setExpiresAt(LocalDateTime.now().plusDays(1));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(10L);
|
||||||
|
user.setUsername("admin");
|
||||||
|
user.setRole(Role.ADMIN);
|
||||||
|
user.setDepartmentId(2L);
|
||||||
|
user.setFullName("Администратор");
|
||||||
|
user.setJobTitle("Администратор");
|
||||||
|
user.setPassword("hash");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TenantConfigWatcherTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configHashUsesSha256Hex() {
|
||||||
|
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
|
||||||
|
|
||||||
|
assertThat(hash).hasSize(64);
|
||||||
|
assertThat(hash).matches("[0-9a-f]{64}");
|
||||||
|
assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configHashChangesWhenContentChanges() {
|
||||||
|
assertThat(TenantConfigWatcher.configHash("tenant-a"))
|
||||||
|
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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 org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class AuthControllerTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearAuthContext() {
|
||||||
|
AuthContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void meAllowsNullDepartmentId() {
|
||||||
|
AuthController controller = new AuthController(null, null, null, null, null);
|
||||||
|
AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null));
|
||||||
|
|
||||||
|
ResponseEntity<?> response = controller.me();
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isInstanceOf(Map.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||||
|
assertThat(body).containsEntry("userId", 10L);
|
||||||
|
assertThat(body).containsEntry("username", "admin");
|
||||||
|
assertThat(body).containsEntry("role", "ADMIN");
|
||||||
|
assertThat(body).containsKey("departmentId");
|
||||||
|
assertThat(body.get("departmentId")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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.repository.SubjectCommentRepository;
|
||||||
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class DepartmentWorkspaceControllerTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearAuthContext() {
|
||||||
|
AuthContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() {
|
||||||
|
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setId(10L);
|
||||||
|
subject.setDepartmentId(null);
|
||||||
|
when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject));
|
||||||
|
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||||
|
subjectRepository,
|
||||||
|
mock(SubjectCommentRepository.class),
|
||||||
|
mock(UserRepository.class),
|
||||||
|
mock(ScheduleQueryService.class)
|
||||||
|
);
|
||||||
|
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||||
|
|
||||||
|
var response = controller.getComments(10L);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class UserControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teachersUseUnifiedUserResponseMapping() {
|
||||||
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
|
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||||
|
UserController controller = new UserController(
|
||||||
|
userRepository,
|
||||||
|
null,
|
||||||
|
departmentRepository,
|
||||||
|
mock(TeacherDepartmentAssignmentRepository.class)
|
||||||
|
);
|
||||||
|
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
||||||
|
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(List.of(teacher));
|
||||||
|
when(departmentRepository.findById(2L))
|
||||||
|
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||||
|
|
||||||
|
var response = controller.getTeachersByDepartmentId(2L);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<UserResponse> body = (List<UserResponse>) response.getBody();
|
||||||
|
assertThat(body).hasSize(1);
|
||||||
|
assertThat(body.get(0).getUsername()).isEqualTo("teacher");
|
||||||
|
assertThat(body.get(0).getDepartmentId()).isEqualTo(2L);
|
||||||
|
assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ");
|
||||||
|
assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(Long id, String username, Role role, Long departmentId) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setRole(role);
|
||||||
|
user.setFullName("Тестовый пользователь");
|
||||||
|
user.setJobTitle("Преподаватель");
|
||||||
|
user.setDepartmentId(departmentId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Classroom;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
import com.magistr.app.repository.ClassroomRepository;
|
||||||
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class WorkloadControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||||
|
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||||
|
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
|
||||||
|
WorkloadController controller = new WorkloadController(
|
||||||
|
scheduleQueryService,
|
||||||
|
classroomRepository,
|
||||||
|
mock(UserRepository.class),
|
||||||
|
mock(DepartmentRepository.class)
|
||||||
|
);
|
||||||
|
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
|
||||||
|
|
||||||
|
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
|
||||||
|
|
||||||
|
assertThat(result).extracting("id").containsExactly(3L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Classroom classroom(Long id, Boolean available) {
|
||||||
|
Classroom classroom = new Classroom();
|
||||||
|
classroom.setId(id);
|
||||||
|
classroom.setName("Аудитория " + id);
|
||||||
|
classroom.setCapacity(30);
|
||||||
|
classroom.setIsAvailable(available);
|
||||||
|
return classroom;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LifecycleEntityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archivedEntityIsNotActiveOnDateWithoutActiveTo() {
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archivedEntityRemainsActiveForHistoricalDatesBeforeActiveTo() {
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
subject.setActiveTo(LocalDate.of(2026, 5, 10));
|
||||||
|
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 9))).isTrue();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 11))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeEntityUsesDateRange() {
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setActiveFrom(LocalDate.of(2026, 1, 10));
|
||||||
|
subject.setActiveTo(LocalDate.of(2026, 1, 20));
|
||||||
|
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ScheduleLessonCategoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsCanonicalLessonTypes() {
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.LECTURE);
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.PRACTICE);
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.LABORATORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAmbiguousLessonType() {
|
||||||
|
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("неоднозначен");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedLessonType() {
|
||||||
|
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("должен быть лекцией");
|
||||||
|
}
|
||||||
|
|
||||||
|
private LessonType lessonType(String name) {
|
||||||
|
LessonType lessonType = new LessonType();
|
||||||
|
lessonType.setLessonType(name);
|
||||||
|
return lessonType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ScheduleRuleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeOnUsesStatusAndValidityRange() {
|
||||||
|
ScheduleRule rule = new ScheduleRule();
|
||||||
|
rule.setValidFrom(LocalDate.of(2026, 2, 1));
|
||||||
|
rule.setValidTo(LocalDate.of(2026, 2, 10));
|
||||||
|
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse();
|
||||||
|
|
||||||
|
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archiveAndRestoreToggleStatus() {
|
||||||
|
ScheduleRule rule = new ScheduleRule();
|
||||||
|
|
||||||
|
rule.archive();
|
||||||
|
|
||||||
|
assertThat(rule.isArchivedRecord()).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.now())).isFalse();
|
||||||
|
|
||||||
|
rule.restore();
|
||||||
|
|
||||||
|
assertThat(rule.isActiveRecord()).isTrue();
|
||||||
|
assertThat(rule.getValidTo()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
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 java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
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 ScheduleQueryServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTooBroadGroupSearch() {
|
||||||
|
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||||
|
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||||
|
when(groupRepository.findAll())
|
||||||
|
.thenReturn(IntStream.rangeClosed(1, 51)
|
||||||
|
.mapToObj(this::group)
|
||||||
|
.toList());
|
||||||
|
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||||
|
ScheduleQueryService service = new ScheduleQueryService(
|
||||||
|
mock(ScheduleGeneratorService.class),
|
||||||
|
groupRepository,
|
||||||
|
mock(ScheduleOverrideRepository.class),
|
||||||
|
groupLifecycleService
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.search(null, null, null, null,
|
||||||
|
null, null, null, null,
|
||||||
|
LocalDate.of(2026, 5, 1),
|
||||||
|
LocalDate.of(2026, 5, 7)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Уточните группу или кафедру");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teacherSearchDoesNotLoadAllGroups() {
|
||||||
|
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||||
|
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||||
|
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||||
|
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||||
|
ScheduleQueryService service = new ScheduleQueryService(
|
||||||
|
generatorService,
|
||||||
|
groupRepository,
|
||||||
|
overrideRepository,
|
||||||
|
groupLifecycleService
|
||||||
|
);
|
||||||
|
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||||
|
LocalDate endDate = LocalDate.of(2026, 5, 7);
|
||||||
|
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
|
||||||
|
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.search(null, 10L, null, null,
|
||||||
|
null, null, null, null,
|
||||||
|
startDate,
|
||||||
|
endDate);
|
||||||
|
|
||||||
|
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
|
||||||
|
verify(groupRepository, never()).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroup group(int id) {
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setId((long) id);
|
||||||
|
group.setName("Группа " + id);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class StudentGroupLifecycleServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() {
|
||||||
|
StudentGroup group = group(1L, 2027);
|
||||||
|
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED);
|
||||||
|
assertThat(service.isAvailableForSelection(group, date)).isTrue();
|
||||||
|
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() {
|
||||||
|
StudentGroup group = group(1L, 2020);
|
||||||
|
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED);
|
||||||
|
assertThat(service.isAvailableForSelection(group, date)).isFalse();
|
||||||
|
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) {
|
||||||
|
StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class);
|
||||||
|
when(repository.findByGroupIdWithDetails(group.getId()))
|
||||||
|
.thenReturn(List.of(assignment(courseCount)));
|
||||||
|
return new StudentGroupLifecycleService(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroup group(Long id, int yearStartStudy) {
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setId(id);
|
||||||
|
group.setName("Группа");
|
||||||
|
group.setYearStartStudy(yearStartStudy);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroupCalendarAssignment assignment(int courseCount) {
|
||||||
|
AcademicCalendar calendar = new AcademicCalendar();
|
||||||
|
calendar.setCourseCount(courseCount);
|
||||||
|
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
|
||||||
|
assignment.setAcademicCalendar(calendar);
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.magistr.app.utils;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CourseAndSemesterCalculatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void courseAndSemesterAreZeroBeforeStudyStart() {
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero();
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void septemberStartsFirstCourseAndFirstSemester() {
|
||||||
|
LocalDate date = LocalDate.of(2026, 9, 1);
|
||||||
|
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1);
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
@@ -4,6 +4,6 @@
|
|||||||
"domain": "default",
|
"domain": "default",
|
||||||
"url": "jdbc:postgresql://db:5432/app_db",
|
"url": "jdbc:postgresql://db:5432/app_db",
|
||||||
"username": "myuser",
|
"username": "myuser",
|
||||||
"password": "supersecretpassword"
|
"password": "replace-with-local-password"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -32,11 +32,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: myuser
|
POSTGRES_USER: ${POSTGRES_USER:-myuser}
|
||||||
POSTGRES_PASSWORD: supersecretpassword
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env}
|
||||||
POSTGRES_DB: app_db
|
POSTGRES_DB: ${POSTGRES_DB:-app_db}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
|
test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
89
docs/API.md
89
docs/API.md
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
||||||
|
|
||||||
|
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-27T19:47:54",
|
||||||
|
"status": 400,
|
||||||
|
"error": "Некорректный запрос",
|
||||||
|
"message": "Некорректные параметры запроса",
|
||||||
|
"path": "/api/schedule"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
@@ -23,7 +37,7 @@
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "OK",
|
"message": "OK",
|
||||||
"token": "550e8400-e29b-41d4-a716-446655440000",
|
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||||
"role": "ADMIN",
|
"role": "ADMIN",
|
||||||
"redirect": "/admin/",
|
"redirect": "/admin/",
|
||||||
"departmentId": 1,
|
"departmentId": 1,
|
||||||
@@ -31,6 +45,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
|
||||||
|
|
||||||
**Ошибка (401):**
|
**Ошибка (401):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -44,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
|
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
||||||
|
|
||||||
@@ -59,6 +75,37 @@ Redirect по ролям:
|
|||||||
| `TEACHER` | `/teacher/` |
|
| `TEACHER` | `/teacher/` |
|
||||||
| `STUDENT` | `/student/` |
|
| `STUDENT` | `/student/` |
|
||||||
|
|
||||||
|
### `POST /api/auth/refresh`
|
||||||
|
|
||||||
|
Обновляет access JWT по refresh-cookie. Тело запроса не требуется.
|
||||||
|
|
||||||
|
**Успешный ответ (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "OK",
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||||
|
"role": "ADMIN",
|
||||||
|
"redirect": "/admin/",
|
||||||
|
"departmentId": 1,
|
||||||
|
"userId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
|
||||||
|
|
||||||
|
### `POST /api/auth/logout`
|
||||||
|
|
||||||
|
Отзывает текущий refresh-токен и очищает refresh-cookie.
|
||||||
|
|
||||||
|
**Успешный ответ (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Выход выполнен"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `GET /api/auth/me`
|
### `GET /api/auth/me`
|
||||||
|
|
||||||
Возвращает текущего пользователя по bearer-токену.
|
Возвращает текущего пользователя по bearer-токену.
|
||||||
@@ -72,6 +119,8 @@ Redirect по ролям:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Пользователи
|
## Пользователи
|
||||||
@@ -83,18 +132,20 @@ Redirect по ролям:
|
|||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
|
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
|
||||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
|
{ "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||||
|
|
||||||
### `GET /api/users/teachers`
|
### `GET /api/users/teachers`
|
||||||
|
|
||||||
Список только преподавателей (роль `TEACHER`).
|
Список только преподавателей (роль `TEACHER`).
|
||||||
|
|
||||||
### `GET /api/users/teachers/{departmentId}`
|
### `GET /api/users/teachers/{departmentId}`
|
||||||
|
|
||||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
|
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||||
|
|
||||||
### `POST /api/users`
|
### `POST /api/users`
|
||||||
|
|
||||||
@@ -151,7 +202,7 @@ Redirect по ролям:
|
|||||||
|
|
||||||
## Права ролей на API
|
## Права ролей на API
|
||||||
|
|
||||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`.
|
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`.
|
||||||
|
|
||||||
Важные ограничения:
|
Важные ограничения:
|
||||||
|
|
||||||
@@ -413,6 +464,8 @@ CRUD доступен по:
|
|||||||
| `timeSlotId` | Временной слот |
|
| `timeSlotId` | Временной слот |
|
||||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||||
|
|
||||||
|
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -546,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
|
|
||||||
### `GET /api/groups`
|
### `GET /api/groups`
|
||||||
|
|
||||||
Список всех групп.
|
Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
|
||||||
|
|
||||||
|
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||||||
|
|
||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
@@ -566,14 +621,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
"specialtyName": "Программная инженерия",
|
"specialtyName": "Программная инженерия",
|
||||||
"specialtyProfileId": 2,
|
"specialtyProfileId": 2,
|
||||||
"specialtyProfileName": "Без профиля",
|
"specialtyProfileName": "Без профиля",
|
||||||
"specialityCode": 1
|
"specialityCode": 1,
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"active": true,
|
||||||
|
"studyState": "ACTIVE",
|
||||||
|
"studyStateName": "Активна"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/groups/{departmentId}`
|
### `GET /api/groups/{departmentId}`
|
||||||
|
|
||||||
Список всех групп привязанных к конкретной кафедре.
|
Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
|
||||||
|
|
||||||
### `POST /api/groups`
|
### `POST /api/groups`
|
||||||
|
|
||||||
@@ -591,7 +650,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`.
|
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
|
||||||
|
|
||||||
|
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||||||
|
|
||||||
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
|
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
|
||||||
|
|
||||||
@@ -613,7 +674,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
|
|
||||||
### `DELETE /api/groups/{id}`
|
### `DELETE /api/groups/{id}`
|
||||||
|
|
||||||
Удаление группы.
|
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||||||
|
|
||||||
|
### `POST /api/groups/{id}/restore`
|
||||||
|
|
||||||
|
Восстановление архивной группы.
|
||||||
|
|
||||||
### Подгруппы группы
|
### Подгруппы группы
|
||||||
|
|
||||||
@@ -906,7 +971,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
| Код | Описание |
|
| Код | Описание |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| `200` | Успех |
|
| `200` | Успех |
|
||||||
| `400` | Ошибка валидации (с `message` в теле) |
|
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||||
| `401` | Неверные учётные данные |
|
| `401` | Неверные учётные данные |
|
||||||
| `404` | Ресурс / тенант не найден |
|
| `404` | Ресурс / тенант не найден |
|
||||||
| `500` | Внутренняя ошибка сервера |
|
| `500` | Внутренняя ошибка сервера |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ graph TD
|
|||||||
- **Порт:** 8080 (внутренний)
|
- **Порт:** 8080 (внутренний)
|
||||||
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
||||||
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
||||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
|
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
|
||||||
|
|
||||||
### PostgreSQL
|
### PostgreSQL
|
||||||
- **Версия:** `postgres:alpine3.23`
|
- **Версия:** `postgres:alpine3.23`
|
||||||
@@ -80,6 +80,7 @@ sequenceDiagram
|
|||||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
||||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||||
|
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
||||||
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
|
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
|
||||||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||||
@@ -99,7 +100,7 @@ sequenceDiagram
|
|||||||
### Конфигурация тенантов
|
### Конфигурация тенантов
|
||||||
|
|
||||||
Список тенантов хранится в JSON-файле:
|
Список тенантов хранится в JSON-файле:
|
||||||
- **Локально:** `backend/tenants.json`
|
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
|
||||||
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
|
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
|
||||||
|
|
||||||
Формат:
|
Формат:
|
||||||
@@ -121,26 +122,29 @@ sequenceDiagram
|
|||||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
||||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
|
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
|
||||||
|
|
||||||
|
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
|
||||||
|
|
||||||
### Fallback при отсутствии тенантов
|
### Fallback при отсутствии тенантов
|
||||||
|
|
||||||
Если при запуске нет ни одного настроенного тенанта:
|
Если при запуске нет ни одного настроенного тенанта:
|
||||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||||
|
|
||||||
|
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
Система использует простую модель аутентификации без JWT и без полноценного Spring Security:
|
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||||||
|
|
||||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
|
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
|
||||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`
|
2. Backend проверяет пароль через `BCryptPasswordEncoder`.
|
||||||
3. При успехе возвращается:
|
3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
|
||||||
- UUID-токен (для заголовка `Authorization: Bearer`)
|
4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
|
||||||
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`)
|
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
|
||||||
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`)
|
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
|
||||||
4. Токен хранится в `localStorage` на клиенте
|
|
||||||
|
|
||||||
Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно.
|
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
|
||||||
|
|
||||||
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution.
|
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
### Учебные группы (Student Groups)
|
### Учебные группы (Student Groups)
|
||||||
|
|
||||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
|
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||||
|
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||||
|
|
||||||
### Аудитории (Classrooms)
|
### Аудитории (Classrooms)
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
|
|
||||||
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
||||||
|
|
||||||
|
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
|
||||||
|
|
||||||
### Перевод преподавателей между кафедрами
|
### Перевод преподавателей между кафедрами
|
||||||
|
|
||||||
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
||||||
@@ -136,6 +139,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||||
|
|
||||||
|
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
|
||||||
|
|
||||||
|
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||||
|
|
||||||
|
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
|
||||||
|
|
||||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||||
|
|
||||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||||
@@ -167,6 +176,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||||
|
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||||
|
|
||||||
### Точечные изменения расписания
|
### Точечные изменения расписания
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ erDiagram
|
|||||||
TIMESTAMP updated_at
|
TIMESTAMP updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auth_refresh_tokens {
|
||||||
|
BIGSERIAL id PK
|
||||||
|
BIGINT user_id FK
|
||||||
|
VARCHAR tenant
|
||||||
|
VARCHAR token_hash UK
|
||||||
|
TIMESTAMP issued_at
|
||||||
|
TIMESTAMP expires_at
|
||||||
|
TIMESTAMP revoked_at
|
||||||
|
VARCHAR rotated_to_token_hash
|
||||||
|
}
|
||||||
|
|
||||||
education_forms {
|
education_forms {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
VARCHAR name UK
|
VARCHAR name UK
|
||||||
@@ -292,6 +303,7 @@ erDiagram
|
|||||||
student_groups ||--o{ schedule_rule_groups : "group_id"
|
student_groups ||--o{ schedule_rule_groups : "group_id"
|
||||||
student_groups ||--o{ student_group_calendar_assignments : "group_id"
|
student_groups ||--o{ student_group_calendar_assignments : "group_id"
|
||||||
users ||--o{ teacher_subjects : "user_id"
|
users ||--o{ teacher_subjects : "user_id"
|
||||||
|
users ||--o{ auth_refresh_tokens : "user_id"
|
||||||
users ||--o{ teacher_department_assignments : "teacher_id"
|
users ||--o{ teacher_department_assignments : "teacher_id"
|
||||||
departments ||--o{ teacher_department_assignments : "department_id"
|
departments ||--o{ teacher_department_assignments : "department_id"
|
||||||
users ||--o{ teacher_lesson_types : "user_id"
|
users ||--o{ teacher_lesson_types : "user_id"
|
||||||
@@ -379,6 +391,22 @@ erDiagram
|
|||||||
|
|
||||||
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
||||||
|
|
||||||
|
#### `auth_refresh_tokens` — Refresh-сессии JWT
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `id` | BIGSERIAL PK | ID refresh-сессии |
|
||||||
|
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
||||||
|
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
||||||
|
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
||||||
|
| `issued_at` | TIMESTAMP | Дата выдачи |
|
||||||
|
| `expires_at` | TIMESTAMP | Дата истечения |
|
||||||
|
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
|
||||||
|
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
||||||
|
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
||||||
|
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
||||||
|
|
||||||
|
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
|
||||||
|
|
||||||
### Учебный процесс
|
### Учебный процесс
|
||||||
|
|
||||||
#### `education_forms` — Формы обучения
|
#### `education_forms` — Формы обучения
|
||||||
@@ -401,6 +429,11 @@ erDiagram
|
|||||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
||||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
||||||
|
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
||||||
|
| `active_from` | DATE | Дата начала действия группы |
|
||||||
|
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
||||||
|
| `archived_at` | TIMESTAMP | Дата и время архивирования |
|
||||||
|
| `archive_reason` | TEXT | Причина архивирования |
|
||||||
|
|
||||||
#### `subgroups` — Подгруппы
|
#### `subgroups` — Подгруппы
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
@@ -616,6 +649,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||||
| `change_reason` | TEXT | Причина изменения |
|
| `change_reason` | TEXT | Причина изменения |
|
||||||
|
|
||||||
|
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
|
||||||
|
|
||||||
#### `schedule_rule_groups` — Группы правила
|
#### `schedule_rule_groups` — Группы правила
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
@@ -674,17 +709,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
|
|
||||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
||||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
|
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
||||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||||
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
|
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
|
||||||
|
|
||||||
> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных.
|
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
|
||||||
|
|
||||||
### Текущие миграции
|
### Текущие миграции
|
||||||
|
|
||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||||
|
|
||||||
### Накатывание на существующих тенантов
|
### Накатывание на существующих тенантов
|
||||||
|
|
||||||
|
|||||||
@@ -222,11 +222,13 @@ public class AbsenceController {
|
|||||||
|
|
||||||
### Правила
|
### Правила
|
||||||
|
|
||||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций
|
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
||||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
||||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
||||||
4. После добавления — перезапустите backend для применения
|
4. После добавления — перезапустите backend для применения
|
||||||
|
|
||||||
|
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
||||||
|
|
||||||
### Применение
|
### Применение
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -252,11 +254,13 @@ com.magistr.app/
|
|||||||
│ ├── TenantContext.java # ThreadLocal текущего тенанта
|
│ ├── TenantContext.java # ThreadLocal текущего тенанта
|
||||||
│ ├── TenantInterceptor.java # Определение тенанта из Host
|
│ ├── TenantInterceptor.java # Определение тенанта из Host
|
||||||
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
|
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
|
||||||
│ ├── TenantDataSourceConfig.java # Spring-конфигурация
|
│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
|
||||||
|
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
|
||||||
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
|
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
|
||||||
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
|
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
|
||||||
├── controller/ # REST-контроллеры
|
├── controller/ # REST-контроллеры
|
||||||
│ ├── AuthController.java
|
│ ├── AuthController.java
|
||||||
|
│ ├── GlobalExceptionHandler.java
|
||||||
│ ├── ScheduleController.java
|
│ ├── ScheduleController.java
|
||||||
│ ├── ClassroomController.java
|
│ ├── ClassroomController.java
|
||||||
│ ├── DatabaseController.java
|
│ ├── DatabaseController.java
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ frontend/
|
|||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
@@ -171,19 +171,24 @@ frontend/
|
|||||||
|
|
||||||
## API-клиент (`api.js`)
|
## API-клиент (`api.js`)
|
||||||
|
|
||||||
Все HTTP-запросы проходят через обёртку `apiFetch()`:
|
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
export async function apiFetch(endpoint, method = 'GET', body = null) {
|
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: getHeaders(body ? 'application/json' : null),
|
||||||
'Authorization': `Bearer ${token}`,
|
credentials: 'same-origin',
|
||||||
'Content-Type': 'application/json'
|
body: body ? JSON.stringify(body) : undefined
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 && retryOnUnauthorized) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) return apiFetch(endpoint, method, body, false);
|
||||||
|
clearAuthState();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ export const api = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Токен берётся из `localStorage.getItem('token')`.
|
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,11 +216,12 @@ export const api = {
|
|||||||
1. Пользователь вводит логин/пароль
|
1. Пользователь вводит логин/пароль
|
||||||
2. `script.js` отправляет `POST /api/auth/login`
|
2. `script.js` отправляет `POST /api/auth/login`
|
||||||
3. При успехе сохраняет в `localStorage`:
|
3. При успехе сохраняет в `localStorage`:
|
||||||
- `token` — UUID-токен
|
- `token` — access JWT
|
||||||
- `role` — роль пользователя
|
- `role` — роль пользователя
|
||||||
- `departmentId` — кафедра пользователя
|
- `departmentId` — кафедра пользователя
|
||||||
- `userId` — ID пользователя для личного расписания преподавателя
|
- `userId` — ID пользователя для личного расписания преподавателя
|
||||||
4. Перенаправляет на соответствующий интерфейс:
|
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
|
||||||
|
5. Перенаправляет на соответствующий интерфейс:
|
||||||
- `ADMIN` → `/admin/`
|
- `ADMIN` → `/admin/`
|
||||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||||
@@ -230,13 +236,13 @@ export const api = {
|
|||||||
```javascript
|
```javascript
|
||||||
export function isAuthenticatedAsAdmin() {
|
export function isAuthenticatedAsAdmin() {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
return token && role === 'ADMIN';
|
return getToken() && role === 'ADMIN';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Выход
|
### Выход
|
||||||
|
|
||||||
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
|
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,21 @@ docker network create proxy
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
POSTGRES_USER=myuser
|
POSTGRES_USER=myuser
|
||||||
POSTGRES_PASSWORD=supersecretpassword
|
POSTGRES_PASSWORD=replace-with-local-password
|
||||||
|
POSTGRES_DB=app_db
|
||||||
|
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
|
||||||
|
JWT_ACCESS_TOKEN_TTL=15m
|
||||||
|
JWT_REFRESH_TOKEN_TTL=7d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||||||
|
|
||||||
|
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
|
||||||
|
|
||||||
### Dockerfile (Backend)
|
### Dockerfile (Backend)
|
||||||
|
|
||||||
Backend собирается через multi-stage сборку Maven:
|
Backend собирается через multi-stage сборку Maven:
|
||||||
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package`
|
1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
|
||||||
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
||||||
|
|
||||||
### Dockerfile (Frontend)
|
### Dockerfile (Frontend)
|
||||||
@@ -58,6 +66,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
|||||||
| `frontend` | Deployment | Apache httpd |
|
| `frontend` | Deployment | Apache httpd |
|
||||||
| `tenants-config` | ConfigMap | JSON-список тенантов |
|
| `tenants-config` | ConfigMap | JSON-список тенантов |
|
||||||
|
|
||||||
|
### JWT настройки
|
||||||
|
|
||||||
|
`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
|
||||||
|
|
||||||
|
- `JWT_ACCESS_TOKEN_TTL=15m`
|
||||||
|
- `JWT_REFRESH_TOKEN_TTL=7d`
|
||||||
|
- `JWT_REFRESH_COOKIE_SECURE=true`
|
||||||
|
|
||||||
|
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
|
||||||
|
|
||||||
### ConfigMap для тенантов
|
### ConfigMap для тенантов
|
||||||
|
|
||||||
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
|
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
const token = localStorage.getItem('token');
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
return token;
|
return localStorage.getItem('token');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticatedAsAdmin() {
|
export function isAuthenticatedAsAdmin() {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
return token && role === 'ADMIN';
|
return getToken() && role === 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticatedAsRole(expectedRole) {
|
export function isAuthenticatedAsRole(expectedRole) {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
return token && role === expectedRole;
|
return getToken() && role === expectedRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticatedAsAny(roles) {
|
export function isAuthenticatedAsAny(roles) {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
return token && roles.includes(role);
|
return getToken() && roles.includes(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaders(contentType = 'application/json') {
|
function getHeaders(contentType = 'application/json') {
|
||||||
const headers = {
|
const headers = {};
|
||||||
'Authorization': `Bearer ${token}`
|
const token = getToken();
|
||||||
};
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
if (contentType) {
|
if (contentType) {
|
||||||
headers['Content-Type'] = contentType;
|
headers['Content-Type'] = contentType;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch(endpoint, method = 'GET', body = null) {
|
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||||
const options = {
|
const options = {
|
||||||
method,
|
method,
|
||||||
headers: getHeaders(body ? 'application/json' : null)
|
headers: getHeaders(body ? 'application/json' : null),
|
||||||
|
credentials: 'same-origin'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
@@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
|||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 && retryOnUnauthorized) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) {
|
||||||
|
return apiFetch(endpoint, method, body, false);
|
||||||
|
}
|
||||||
|
clearAuthState();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken() {
|
||||||
|
if (refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !data?.token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
storeAuthState(data);
|
||||||
|
return true;
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Не удалось завершить серверную сессию:', e.message);
|
||||||
|
} finally {
|
||||||
|
clearAuthState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeAuthState(data) {
|
||||||
|
if (data.token) localStorage.setItem('token', data.token);
|
||||||
|
if (data.role) localStorage.setItem('role', data.role);
|
||||||
|
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||||
|
localStorage.setItem('departmentId', data.departmentId);
|
||||||
|
}
|
||||||
|
if (data.userId !== undefined && data.userId !== null) {
|
||||||
|
localStorage.setItem('userId', data.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthState() {
|
||||||
|
AUTH_KEYS.forEach(key => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (url) => apiFetch(url, 'GET'),
|
get: (url) => apiFetch(url, 'GET'),
|
||||||
post: (url, body) => apiFetch(url, 'POST', body),
|
post: (url, body) => apiFetch(url, 'POST', body),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
|||||||
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
import { isAuthenticatedAsAny } from './api.js';
|
import { isAuthenticatedAsAny, logout } from './api.js';
|
||||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
||||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||||
|
|
||||||
@@ -143,9 +143,8 @@ document.addEventListener('click', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
btnLogout.addEventListener('click', () => {
|
btnLogout.addEventListener('click', async () => {
|
||||||
localStorage.removeItem('token');
|
await logout();
|
||||||
localStorage.removeItem('role');
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export async function initGroups() {
|
|||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
await loadSubgroups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,13 +103,13 @@ export async function initGroups() {
|
|||||||
|
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
try {
|
try {
|
||||||
allGroups = await api.get('/api/groups');
|
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||||
applyGroupFilter();
|
applyGroupFilter();
|
||||||
populateGroupSelect();
|
populateGroupSelect();
|
||||||
populateSubgroupGroupSelect();
|
populateSubgroupGroupSelect();
|
||||||
await loadAssignments();
|
await loadAssignments();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ export async function initGroups() {
|
|||||||
|
|
||||||
function renderGroups(groups) {
|
function renderGroups(groups) {
|
||||||
if (!groups || !groups.length) {
|
if (!groups || !groups.length) {
|
||||||
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
|
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
groupsTbody.innerHTML = groups.map(group => `
|
groupsTbody.innerHTML = groups.map(group => `
|
||||||
@@ -145,9 +145,12 @@ export async function initGroups() {
|
|||||||
<td>${group.course || '-'}</td>
|
<td>${group.course || '-'}</td>
|
||||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||||
|
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||||
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
|
${group.status === 'ARCHIVED'
|
||||||
|
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||||
|
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -232,24 +235,26 @@ export async function initGroups() {
|
|||||||
|
|
||||||
function populateGroupSelect() {
|
function populateGroupSelect() {
|
||||||
const current = calendarGroupSelect.value;
|
const current = calendarGroupSelect.value;
|
||||||
|
const availableGroups = allGroups.filter(group => group.active);
|
||||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||||
calendarGroupSelect.value = current;
|
calendarGroupSelect.value = current;
|
||||||
} else if (allGroups.length) {
|
} else if (availableGroups.length) {
|
||||||
calendarGroupSelect.value = String(allGroups[0].id);
|
calendarGroupSelect.value = String(availableGroups[0].id);
|
||||||
}
|
}
|
||||||
syncSelects(calendarGroupSelect);
|
syncSelects(calendarGroupSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSubgroupGroupSelect() {
|
function populateSubgroupGroupSelect() {
|
||||||
const current = subgroupGroupSelect.value;
|
const current = subgroupGroupSelect.value;
|
||||||
|
const availableGroups = allGroups.filter(group => group.active);
|
||||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||||
subgroupGroupSelect.value = current;
|
subgroupGroupSelect.value = current;
|
||||||
} else if (allGroups.length) {
|
} else if (availableGroups.length) {
|
||||||
subgroupGroupSelect.value = String(allGroups[0].id);
|
subgroupGroupSelect.value = String(availableGroups[0].id);
|
||||||
}
|
}
|
||||||
syncSelects(subgroupGroupSelect);
|
syncSelects(subgroupGroupSelect);
|
||||||
}
|
}
|
||||||
@@ -342,15 +347,26 @@ export async function initGroups() {
|
|||||||
async function handleGroupTableClick(event) {
|
async function handleGroupTableClick(event) {
|
||||||
const btnDelete = event.target.closest('.btn-delete');
|
const btnDelete = event.target.closest('.btn-delete');
|
||||||
const btnEdit = event.target.closest('.btn-edit-group');
|
const btnEdit = event.target.closest('.btn-edit-group');
|
||||||
|
const btnRestore = event.target.closest('.btn-restore-group');
|
||||||
|
|
||||||
if (btnDelete) {
|
if (btnDelete) {
|
||||||
if (!confirm('Удалить группу?')) return;
|
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
await loadSubgroups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error.message || 'Ошибка удаления');
|
alert(error.message || 'Ошибка архивирования');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnRestore) {
|
||||||
|
try {
|
||||||
|
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||||
|
await loadGroups();
|
||||||
|
await loadSubgroups();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Ошибка восстановления');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,6 +582,21 @@ export async function initGroups() {
|
|||||||
return name ? `${code} — ${name}` : code;
|
return name ? `${code} — ${name}` : code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(group) {
|
||||||
|
if (group.status === 'ARCHIVED') return 'Архив';
|
||||||
|
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(group) {
|
||||||
|
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||||
|
return 'badge-unavailable';
|
||||||
|
}
|
||||||
|
if (group.studyState === 'NOT_STARTED') {
|
||||||
|
return 'badge-ef';
|
||||||
|
}
|
||||||
|
return 'badge-available';
|
||||||
|
}
|
||||||
|
|
||||||
function syncSelects(...selects) {
|
function syncSelects(...selects) {
|
||||||
selects.filter(Boolean).forEach(select => {
|
selects.filter(Boolean).forEach(select => {
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Основной модуль страницы настроек
|
// Основной модуль страницы настроек
|
||||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
||||||
|
|
||||||
|
import { getToken } from '../../js/api.js';
|
||||||
|
|
||||||
// Проверка авторизации
|
// Проверка авторизации
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
|
if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,13 @@
|
|||||||
<th>Курс</th>
|
<th>Курс</th>
|
||||||
<th>Специальность</th>
|
<th>Специальность</th>
|
||||||
<th>Профиль</th>
|
<th>Профиль</th>
|
||||||
|
<th>Статус</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="groups-tbody">
|
<tbody id="groups-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="loading-row">Загрузка...</td>
|
<td colspan="10" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -127,23 +127,28 @@
|
|||||||
hideAlert();
|
hideAlert();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
credentials: 'same-origin',
|
||||||
username: usernameInput.value.trim(),
|
body: JSON.stringify({
|
||||||
password: passwordInput.value,
|
username: usernameInput.value.trim(),
|
||||||
}),
|
password: passwordInput.value,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showAlert('Вход выполнен успешно!', 'success');
|
showAlert('Вход выполнен успешно!', 'success');
|
||||||
|
|
||||||
if (data.token) localStorage.setItem('token', data.token);
|
localStorage.removeItem('token');
|
||||||
if (data.role) localStorage.setItem('role', data.role);
|
localStorage.removeItem('role');
|
||||||
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
localStorage.removeItem('departmentId');
|
||||||
|
localStorage.removeItem('userId');
|
||||||
|
if (data.token) localStorage.setItem('token', data.token);
|
||||||
|
if (data.role) localStorage.setItem('role', data.role);
|
||||||
|
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
||||||
if (data.userId) localStorage.setItem('userId', data.userId);
|
if (data.userId) localStorage.setItem('userId', data.userId);
|
||||||
|
|
||||||
const redirect = data.redirect || '/';
|
const redirect = data.redirect || '/';
|
||||||
|
|||||||
@@ -292,19 +292,18 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const groupSelect = document.getElementById('group-select');
|
const groupSelect = document.getElementById('group-select');
|
||||||
const grid = document.getElementById('schedule-grid');
|
const grid = document.getElementById('schedule-grid');
|
||||||
const statusLine = document.getElementById('status-line');
|
const statusLine = document.getElementById('status-line');
|
||||||
const weekLabel = document.getElementById('week-label');
|
const weekLabel = document.getElementById('week-label');
|
||||||
const datePicker = document.getElementById('date-picker');
|
const datePicker = document.getElementById('date-picker');
|
||||||
let weekStart = startOfWeek(new Date());
|
let weekStart = startOfWeek(new Date());
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
document.getElementById('logout-link').addEventListener('click', () => {
|
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||||
localStorage.removeItem('token');
|
event.preventDefault();
|
||||||
localStorage.removeItem('role');
|
await logout();
|
||||||
localStorage.removeItem('departmentId');
|
window.location.href = '/';
|
||||||
localStorage.removeItem('userId');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||||
@@ -444,17 +443,78 @@
|
|||||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url, retryOnUnauthorized = true) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
|
if (response.status === 401 && retryOnUnauthorized) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) {
|
||||||
|
return fetchJson(url, false);
|
||||||
|
}
|
||||||
|
clearAuthState();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !data?.token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
storeAuthState(data);
|
||||||
|
return true;
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Не удалось завершить серверную сессию:', error.message);
|
||||||
|
} finally {
|
||||||
|
clearAuthState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeAuthState(data) {
|
||||||
|
if (data.token) localStorage.setItem('token', data.token);
|
||||||
|
if (data.role) localStorage.setItem('role', data.role);
|
||||||
|
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||||
|
localStorage.setItem('departmentId', data.departmentId);
|
||||||
|
}
|
||||||
|
if (data.userId !== undefined && data.userId !== null) {
|
||||||
|
localStorage.setItem('userId', data.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthState() {
|
||||||
|
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
function showStatus(message, isError) {
|
function showStatus(message, isError) {
|
||||||
statusLine.textContent = message;
|
statusLine.textContent = message;
|
||||||
statusLine.classList.toggle('error', isError);
|
statusLine.classList.toggle('error', isError);
|
||||||
|
|||||||
@@ -295,19 +295,18 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const teacherId = localStorage.getItem('userId');
|
const teacherId = localStorage.getItem('userId');
|
||||||
const grid = document.getElementById('schedule-grid');
|
const grid = document.getElementById('schedule-grid');
|
||||||
const statusLine = document.getElementById('status-line');
|
const statusLine = document.getElementById('status-line');
|
||||||
const weekLabel = document.getElementById('week-label');
|
const weekLabel = document.getElementById('week-label');
|
||||||
const datePicker = document.getElementById('date-picker');
|
const datePicker = document.getElementById('date-picker');
|
||||||
let weekStart = startOfWeek(new Date());
|
let weekStart = startOfWeek(new Date());
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
document.getElementById('logout-link').addEventListener('click', () => {
|
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||||
localStorage.removeItem('token');
|
event.preventDefault();
|
||||||
localStorage.removeItem('role');
|
await logout();
|
||||||
localStorage.removeItem('departmentId');
|
window.location.href = '/';
|
||||||
localStorage.removeItem('userId');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||||
@@ -471,17 +470,78 @@
|
|||||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url, retryOnUnauthorized = true) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
|
if (response.status === 401 && retryOnUnauthorized) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) {
|
||||||
|
return fetchJson(url, false);
|
||||||
|
}
|
||||||
|
clearAuthState();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !data?.token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
storeAuthState(data);
|
||||||
|
return true;
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Не удалось завершить серверную сессию:', error.message);
|
||||||
|
} finally {
|
||||||
|
clearAuthState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeAuthState(data) {
|
||||||
|
if (data.token) localStorage.setItem('token', data.token);
|
||||||
|
if (data.role) localStorage.setItem('role', data.role);
|
||||||
|
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||||
|
localStorage.setItem('departmentId', data.departmentId);
|
||||||
|
}
|
||||||
|
if (data.userId !== undefined && data.userId !== null) {
|
||||||
|
localStorage.setItem('userId', data.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthState() {
|
||||||
|
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
function showStatus(message, isError) {
|
function showStatus(message, isError) {
|
||||||
statusLine.textContent = message;
|
statusLine.textContent = message;
|
||||||
statusLine.classList.toggle('error', isError);
|
statusLine.classList.toggle('error', isError);
|
||||||
|
|||||||
Reference in New Issue
Block a user