v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
"""FastAPI-зависимости аутентификации и авторизации."""
|
||||
from __future__ import annotations
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core import security
|
||||
from app.core.errors import AuthError, ForbiddenError
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, session: Session = Depends(get_session)
|
||||
) -> User:
|
||||
token = request.cookies.get(security.USER_COOKIE)
|
||||
if not token:
|
||||
raise AuthError("Требуется вход.")
|
||||
try:
|
||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||
except jwt.PyJWTError as exc: # noqa: F841
|
||||
raise AuthError("Сессия недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Сессия недействительна.")
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
request: Request, session: Session = Depends(get_session)
|
||||
) -> User:
|
||||
token = request.cookies.get(security.ADMIN_COOKIE)
|
||||
if not token:
|
||||
raise AuthError("Требуется вход администратора.")
|
||||
try:
|
||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||
except jwt.PyJWTError:
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or user.role != "admin" or not user.is_active:
|
||||
raise ForbiddenError("Доступ только для администратора.")
|
||||
return user
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.auth.provider import AuthProvider, ExternalIdentity
|
||||
from app.core.errors import ValidationError
|
||||
|
||||
|
||||
class DevStubProvider(AuthProvider):
|
||||
name = "stub"
|
||||
|
||||
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
|
||||
nickname = (payload.get("nickname") or "").strip()
|
||||
external_id = str(payload.get("external_id") or nickname).strip()
|
||||
if not external_id:
|
||||
raise ValidationError("Укажите никнейм для входа.")
|
||||
return ExternalIdentity(
|
||||
provider=self.name,
|
||||
external_id=external_id,
|
||||
suggested_nickname=nickname or external_id,
|
||||
raw=dict(payload),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
||||
и dev-входом.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core import security
|
||||
from app.core.errors import ForbiddenError
|
||||
from app.models import User
|
||||
from app.services import audit_service, user_service
|
||||
|
||||
|
||||
def login_with_identity(
|
||||
session: Session, response: Response, request: Request, identity: ExternalIdentity
|
||||
) -> User:
|
||||
user = user_service.get_or_create_from_identity(session, identity)
|
||||
if not user.is_active:
|
||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||
security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"provider": identity.provider},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return user
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Абстракция провайдера аутентификации (pluggable)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalIdentity:
|
||||
"""Нормализованная личность от внешнего провайдера."""
|
||||
|
||||
provider: str
|
||||
external_id: str
|
||||
suggested_nickname: str | None = None
|
||||
telegram_id: int | None = None
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AuthProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
|
||||
"""Проверяет вход и возвращает внешнюю личность. Бросает AppError при ошибке."""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Доступные методы входа по окружению.
|
||||
|
||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def enabled_methods() -> list[str]:
|
||||
methods = ["telegram"]
|
||||
if settings.is_development:
|
||||
methods.append("stub")
|
||||
return methods
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Telegram Login Widget провайдер.
|
||||
|
||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.auth.provider import AuthProvider, ExternalIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthError
|
||||
|
||||
_MAX_AUTH_AGE_SECONDS = 86400 # сутки
|
||||
|
||||
|
||||
class TelegramProvider(AuthProvider):
|
||||
name = "telegram"
|
||||
|
||||
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
|
||||
token = settings.telegram_bot_token
|
||||
if not token:
|
||||
raise AuthError("Telegram-вход не настроен (нет TELEGRAM_BOT_TOKEN).")
|
||||
|
||||
data = {k: v for k, v in payload.items() if k != "hash" and v is not None}
|
||||
received_hash = payload.get("hash")
|
||||
if not received_hash:
|
||||
raise AuthError("Отсутствует подпись Telegram.")
|
||||
|
||||
check_string = "\n".join(f"{k}={data[k]}" for k in sorted(data))
|
||||
secret_key = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
computed = hmac.new(
|
||||
secret_key, check_string.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed, str(received_hash)):
|
||||
raise AuthError("Подпись Telegram не прошла проверку.")
|
||||
|
||||
auth_date = int(data.get("auth_date", 0))
|
||||
if auth_date and time.time() - auth_date > _MAX_AUTH_AGE_SECONDS:
|
||||
raise AuthError("Срок действия данных Telegram истёк.")
|
||||
|
||||
tg_id = int(data["id"])
|
||||
username = data.get("username")
|
||||
first = data.get("first_name", "")
|
||||
last = data.get("last_name", "")
|
||||
suggested = username or (f"{first} {last}".strip()) or str(tg_id)
|
||||
|
||||
return ExternalIdentity(
|
||||
provider=self.name,
|
||||
external_id=str(tg_id),
|
||||
suggested_nickname=suggested,
|
||||
telegram_id=tg_id,
|
||||
raw=dict(payload),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||
|
||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.db.session import engine
|
||||
from app.models import User
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
|
||||
|
||||
def _ensure_admin(session: Session) -> None:
|
||||
if not settings.admin_bootstrap_enabled:
|
||||
return
|
||||
password = (settings.admin_password or "").strip()
|
||||
username = (settings.admin_username or "admin").strip()
|
||||
existing = session.exec(select(User).where(User.role == "admin")).first()
|
||||
|
||||
if existing is None:
|
||||
if not password:
|
||||
raise RuntimeError("Отказ создавать администратора без пароля (ADMIN_PASSWORD пуст).")
|
||||
# Логин администратора = его никнейм (отдельной таблицы админов нет).
|
||||
session.add(
|
||||
User(
|
||||
nickname=username,
|
||||
role="admin",
|
||||
auth_provider="local",
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
print(f"[bootstrap] Создан администратор: {username}")
|
||||
return
|
||||
|
||||
# Администратор уже существует.
|
||||
if not settings.is_development:
|
||||
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
||||
return
|
||||
|
||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||
changed: list[str] = []
|
||||
if password and not verify_password(password, existing.password_hash or ""):
|
||||
existing.password_hash = hash_password(password)
|
||||
changed.append("пароль")
|
||||
if username and existing.nickname != username:
|
||||
existing.nickname = username
|
||||
changed.append("логин")
|
||||
if changed:
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session) # идемпотентно
|
||||
_ensure_admin(session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bootstrap()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Конфигурация приложения из переменных окружения (12-factor)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
# и на деве (uvicorn из backend/), и где бы ни была рабочая папка. В Docker файла нет
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=_ROOT_ENV,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# ── Главный переключатель окружения: development | test | production ───────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
||||
app_tz_offset_hours: int = 3
|
||||
|
||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||
# dev — в папке данных дева; prod — на постоянном томе контейнера.
|
||||
dev_database_url: str = "sqlite:///./data/dev/forbidden_stars.db"
|
||||
prod_database_url: str = "sqlite:////data/forbidden_stars.db"
|
||||
|
||||
# Каталог загрузок (зарезервировано под вложения), тоже раздельно.
|
||||
dev_upload_dir: str = "./data/dev/uploads"
|
||||
prod_upload_dir: str = "/data/uploads"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
cookie_secure: bool = False
|
||||
cookie_domain: str | None = None
|
||||
|
||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
||||
telegram_bot_token: str | None = None
|
||||
telegram_bot_username: str | None = None
|
||||
public_base_url: str | None = None
|
||||
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
# храним как str и режем в свойстве, чтобы pydantic-settings не пытался
|
||||
# распарсить значение как JSON (иначе "http://..." ломает разбор .env).
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
"""Нативная разработка. Только это окружение включает dev-вход (stub),
|
||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||
return self.app_env.lower() == "development"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
return self.app_env.lower() == "test"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> str:
|
||||
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
|
||||
|
||||
@property
|
||||
def cookie_domain_value(self) -> str | None:
|
||||
return self.cookie_domain or None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Доменные исключения и единый конверт ошибок API.
|
||||
|
||||
Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Базовая ошибка приложения со стабильным машинным кодом."""
|
||||
|
||||
status_code: int = 400
|
||||
code: str = "BAD_REQUEST"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
status_code: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
def to_response(self) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=self.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
code = "NOT_FOUND"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code = 403
|
||||
code = "FORBIDDEN"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 422
|
||||
code = "VALIDATION_ERROR"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
code = "CONFLICT"
|
||||
|
||||
|
||||
class AuthError(AppError):
|
||||
status_code = 401
|
||||
code = "UNAUTHORIZED"
|
||||
|
||||
|
||||
# ─── Конкретные ошибки со стабильными кодами для фронта ───────────────────────
|
||||
|
||||
class NoGroupError(AppError):
|
||||
status_code = 409
|
||||
code = "NO_GROUP"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Вы не состоите ни в одной группе.")
|
||||
|
||||
|
||||
class NotGroupMemberError(ForbiddenError):
|
||||
code = "NOT_GROUP_MEMBER"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Вы не являетесь участником этой группы.")
|
||||
|
||||
|
||||
class NicknameTakenError(ConflictError):
|
||||
code = "NICKNAME_TAKEN"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Никнейм уже занят.")
|
||||
|
||||
|
||||
class FactionNotAvailableError(ValidationError):
|
||||
code = "FACTION_NOT_AVAILABLE"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Фракция недоступна выбранной группе.")
|
||||
|
||||
|
||||
class DuplicateParticipantError(ValidationError):
|
||||
code = "DUPLICATE_PARTICIPANT"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Игрок или фракция повторяются в партии.")
|
||||
|
||||
|
||||
class InvalidRankingError(ValidationError):
|
||||
code = "INVALID_RANKING"
|
||||
|
||||
def __init__(self, message: str = "Некорректная расстановка мест.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InvalidCredentialsError(AuthError):
|
||||
code = "INVALID_CREDENTIALS"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Неверный логин или пароль.")
|
||||
|
||||
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
return exc.to_response()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Безопасность: bcrypt, JWT, cookie сессии и CSRF (double-submit)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
CSRF_HEADER = "x-csrf-token"
|
||||
|
||||
AUDIENCE_USER = "user"
|
||||
AUDIENCE_ADMIN = "admin"
|
||||
|
||||
_ADMIN_PATH = "/api/admin"
|
||||
_USER_PATH = "/"
|
||||
|
||||
|
||||
# ─── Пароли ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str, audience: str) -> dict:
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
|
||||
# ─── Cookie сессии + CSRF ────────────────────────────────────────────────────
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
csrf = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
max_age=max_age,
|
||||
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
return csrf
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
ttl = settings.jwt_user_ttl_minutes
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
max_age=ttl * 60,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path=_USER_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
ttl = settings.jwt_admin_ttl_minutes
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
max_age=ttl * 60,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path=_ADMIN_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
response.delete_cookie(USER_COOKIE, path=_USER_PATH, domain=settings.cookie_domain_value)
|
||||
|
||||
|
||||
def clear_admin_session(response: Response) -> None:
|
||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Работа со временем: хранение в UTC, отдача ISO с явным смещением,
|
||||
«сегодня» в часовом поясе приложения (по умолчанию +3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
APP_TZ = timezone(timedelta(hours=settings.app_tz_offset_hours))
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Текущее время в UTC (aware)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def app_today() -> date:
|
||||
"""Текущая дата в часовом поясе приложения (для «даты игры»)."""
|
||||
return datetime.now(APP_TZ).date()
|
||||
|
||||
|
||||
def iso_utc(dt: datetime | None) -> str | None:
|
||||
"""ISO-строка с явным UTC-смещением. Наивное значение из SQLite считаем UTC,
|
||||
чтобы фронт корректно сконвертировал его в локальный пояс отображения."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
||||
|
||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
||||
где БД поднимается из чистого состояния.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.db.session import engine
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
from sqlmodel import Session
|
||||
|
||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
||||
import app.models # noqa: F401
|
||||
|
||||
|
||||
def create_db_and_seed() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Движок БД, PRAGMA для SQLite и зависимость сессии."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _ensure_sqlite_dir(url: str) -> None:
|
||||
"""Создаёт каталог для файла SQLite (data/dev в деве, /data на томе в проде)."""
|
||||
prefix = "sqlite:///"
|
||||
if not url.startswith(prefix):
|
||||
return
|
||||
path = url[len(prefix):]
|
||||
if path.startswith("/"): # абсолютный путь (sqlite:////...)
|
||||
file_path = path
|
||||
else:
|
||||
file_path = path
|
||||
directory = os.path.dirname(file_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
|
||||
_ensure_sqlite_dir(settings.database_url)
|
||||
|
||||
# check_same_thread=False — FastAPI работает в нескольких потоках.
|
||||
_connect_args = (
|
||||
{"check_same_thread": False}
|
||||
if settings.database_url.startswith("sqlite")
|
||||
else {}
|
||||
)
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
connect_args=_connect_args,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None: # noqa: ANN001
|
||||
"""Включает FK и настраивает WAL на каждом соединении SQLite."""
|
||||
# Срабатывает для всех движков; PRAGMA применяем только к sqlite3.
|
||||
if dbapi_connection.__class__.__module__.startswith("sqlite3"):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=30000")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def get_session() -> Iterator[Session]:
|
||||
"""FastAPI-зависимость: сессия на запрос."""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Фабрика приложения FastAPI: API под /api + отдача собранного SPA."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError, app_error_handler
|
||||
from app.routers import admin, auth, groups, matches, reference, stats, users
|
||||
|
||||
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
|
||||
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
|
||||
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Double-submit CSRF: для аутентифицированных мутаций на /api требуем
|
||||
совпадения заголовка X-CSRF-Token и cookie csrf_token."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next): # noqa: ANN001
|
||||
path = request.url.path
|
||||
if request.method in _UNSAFE_METHODS and path.startswith("/api"):
|
||||
has_session = (
|
||||
security.USER_COOKIE in request.cookies
|
||||
or security.ADMIN_COOKIE in request.cookies
|
||||
)
|
||||
if has_session:
|
||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||
header_token = request.headers.get(security.CSRF_HEADER)
|
||||
if not cookie_token or cookie_token != header_token:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": {
|
||||
"code": "CSRF_FAILED",
|
||||
"message": "Неверный или отсутствующий CSRF-токен.",
|
||||
"details": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||
try:
|
||||
from app.bootstrap import bootstrap
|
||||
|
||||
bootstrap()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.getLogger("fs").warning(
|
||||
"Стартовый bootstrap пропущен (примените миграции): %s", exc
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Forbidden Stars API",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
if settings.cors_origins_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
# Обработчики ошибок → единый конверт.
|
||||
app.add_exception_handler(AppError, app_error_handler)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Ошибка валидации запроса.",
|
||||
"details": exc.errors(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# API-роутеры под /api.
|
||||
api_routers = [auth.router, users.router, groups.router, matches.router,
|
||||
reference.router, stats.router, admin.router]
|
||||
for r in api_routers:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
# DEV-вход (по нику) — только в development и только если код физически есть
|
||||
# (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится).
|
||||
if settings.is_development:
|
||||
try:
|
||||
from app.routers import dev_auth
|
||||
|
||||
app.include_router(dev_auth.router, prefix="/api")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@app.get("/api/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
_mount_spa(app)
|
||||
return app
|
||||
|
||||
|
||||
def _mount_spa(app: FastAPI) -> None:
|
||||
"""Отдаём собранный SPA: статика + fallback на index.html для client-routes."""
|
||||
index_file = _STATIC_DIR / "index.html"
|
||||
if not index_file.exists():
|
||||
return # в dev фронт обслуживает Vite на :5173
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa(full_path: str): # noqa: ANN202
|
||||
# Неизвестный API-путь — это 404 (JSON), а не отдача SPA.
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": {"code": "NOT_FOUND", "message": "Не найдено.", "details": None}},
|
||||
)
|
||||
candidate = (_STATIC_DIR / full_path).resolve()
|
||||
if (
|
||||
full_path
|
||||
and _STATIC_DIR in candidate.parents
|
||||
and candidate.is_file()
|
||||
):
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(index_file)
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""SQLModel-модели (слой БД). Все таблицы v1 + заготовка под вложения.
|
||||
|
||||
Имена столбцов/таблиц — английские; отображаемые имена справочников (RU) — в `name_ru`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
|
||||
|
||||
class Expansion(SQLModel, table=True):
|
||||
__tablename__ = "expansions"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
code: str = Field(sa_column=Column(String(32), nullable=False, unique=True))
|
||||
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
sort_order: int = Field(default=0, nullable=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class Faction(SQLModel, table=True):
|
||||
__tablename__ = "factions"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
code: str = Field(sa_column=Column(String(32), nullable=False, unique=True))
|
||||
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
expansion_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
ForeignKey("expansions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
)
|
||||
sort_order: int = Field(default=0, nullable=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Пользователи и идентичности ─────────────────────────────────────────────
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
CheckConstraint("role IN ('player','admin')", name="ck_users_role"),
|
||||
CheckConstraint(
|
||||
"auth_provider IN ('stub','telegram','local')",
|
||||
name="ck_users_auth_provider",
|
||||
),
|
||||
CheckConstraint(
|
||||
"role <> 'admin' OR password_hash IS NOT NULL",
|
||||
name="ck_users_admin_has_password",
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
telegram_id: int | None = Field(
|
||||
sa_column=Column(BigInteger, nullable=True, unique=True)
|
||||
)
|
||||
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
|
||||
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
|
||||
auth_provider: str = Field(
|
||||
default="stub",
|
||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||
)
|
||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||
active_group_id: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
ForeignKey("groups.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
)
|
||||
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class AuthIdentity(SQLModel, table=True):
|
||||
__tablename__ = "auth_identity"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider", "external_id", name="uq_identity_provider_external"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
provider: str = Field(sa_column=Column(String(16), nullable=False))
|
||||
external_id: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
|
||||
class Group(SQLModel, table=True):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
owner_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class GroupMember(SQLModel, table=True):
|
||||
__tablename__ = "group_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
|
||||
CheckConstraint("role IN ('owner','member')", name="ck_member_role"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
group_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
|
||||
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupExpansion(SQLModel, table=True):
|
||||
__tablename__ = "group_expansions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "expansion_id", name="uq_group_expansion"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
group_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
expansion_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Партии и участники ──────────────────────────────────────────────────────
|
||||
|
||||
class Match(SQLModel, table=True):
|
||||
__tablename__ = "matches"
|
||||
__table_args__ = (
|
||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||
CheckConstraint(
|
||||
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
|
||||
name="ck_match_win_reason",
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
group_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("groups.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
)
|
||||
# Python-default 'in_progress' (новые партии стартуют незавершёнными). Серверного
|
||||
# default нет: статус всегда задаётся ORM явно. Бэкфилл старых строк ('finished')
|
||||
# делает миграция 0003 при ADD COLUMN на существующей БД.
|
||||
status: str = Field(default="in_progress", sa_column=Column(String(16), nullable=False))
|
||||
played_at: date = Field(sa_column=Column(Date, nullable=False))
|
||||
started_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
||||
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_by: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class MatchParticipant(SQLModel, table=True):
|
||||
__tablename__ = "match_participants"
|
||||
__table_args__ = (
|
||||
# Без UNIQUE(match_id, place) — ничьи разрешены (competition ranking 1,2,2,4).
|
||||
UniqueConstraint("match_id", "user_id", name="uq_participant_user"),
|
||||
UniqueConstraint("match_id", "faction_id", name="uq_participant_faction"),
|
||||
CheckConstraint("place >= 1", name="ck_participant_place"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
match_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
faction_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("factions.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
class AuditLog(SQLModel, table=True):
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_entity", "entity_type", "entity_id"),
|
||||
Index("ix_audit_created", "created_at"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
actor_id: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
)
|
||||
action: str = Field(sa_column=Column(String(32), nullable=False))
|
||||
entity_type: str = Field(sa_column=Column(String(32), nullable=False))
|
||||
entity_id: int | None = Field(sa_column=Column(Integer, nullable=True))
|
||||
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
|
||||
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
|
||||
# class Attachment(SQLModel, table=True):
|
||||
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
|
||||
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
|
||||
# size_bytes, created_at
|
||||
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core import security
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.routers.matches import build_match_read
|
||||
from app.schemas import api as s
|
||||
from app.services import admin_service, audit_service, faction_service, match_service
|
||||
from app.services.match_service import ParticipantInput
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
# ─── Аутентификация админа ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/auth/login", response_model=s.AdminMe)
|
||||
def admin_login(
|
||||
body: s.AdminLogin,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.AdminMe:
|
||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="login",
|
||||
entity_type="admin",
|
||||
entity_id=admin.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||
def admin_logout(response: Response) -> s.OkResponse:
|
||||
security.clear_admin_session(response)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@router.get("/me", response_model=s.AdminMe)
|
||||
def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
||||
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
def list_users(
|
||||
query: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminUserRead]:
|
||||
return [
|
||||
s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
)
|
||||
for u in admin_service.list_users(session, query)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: s.AdminUserUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminUserRead:
|
||||
u = admin_service.update_user(session, user_id, nickname=body.nickname, is_active=body.is_active)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload=body.model_dump(exclude_none=True),
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=u.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
admin_service.delete_user(session, user_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/groups", response_model=list[s.AdminGroupRead])
|
||||
def list_groups(
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminGroupRead]:
|
||||
return [
|
||||
s.AdminGroupRead(
|
||||
id=g.id, name=g.name, owner_id=g.owner_id, created_at=iso_utc(g.created_at) # type: ignore[arg-type]
|
||||
)
|
||||
for g in admin_service.list_groups(session)
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", response_model=s.OkResponse)
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
admin_service.delete_group(session, group_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/matches", response_model=list[s.AdminMatchRead])
|
||||
def list_matches(
|
||||
group_id: int | None = Query(None),
|
||||
user_id: int | None = Query(None),
|
||||
faction_id: int | None = Query(None),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminMatchRead]:
|
||||
return [
|
||||
s.AdminMatchRead(
|
||||
id=m.id, # type: ignore[arg-type]
|
||||
group_id=m.group_id,
|
||||
group_name=gname,
|
||||
status=m.status,
|
||||
played_at=str(m.played_at),
|
||||
duration_minutes=m.duration_minutes,
|
||||
win_reason=m.win_reason, # type: ignore[arg-type]
|
||||
player_count=m.player_count,
|
||||
created_by=m.created_by,
|
||||
created_at=iso_utc(m.created_at),
|
||||
)
|
||||
for m, gname in admin_service.list_matches(
|
||||
session,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
faction_id=faction_id,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/matches/{match_id}", response_model=s.MatchRead)
|
||||
def get_match(
|
||||
match_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
return build_match_read(session, match, can_modify=True)
|
||||
|
||||
|
||||
@router.patch("/matches/{match_id}", response_model=s.MatchRead)
|
||||
def update_match(
|
||||
match_id: int,
|
||||
body: s.MatchUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
played_at=body.played_at,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return build_match_read(session, match, can_modify=True)
|
||||
|
||||
|
||||
# ─── Фракции (переименование во всей системе) ─────────────────────────────────
|
||||
|
||||
@router.get("/factions", response_model=list[s.FactionRead])
|
||||
def list_factions(
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.FactionRead]:
|
||||
return [
|
||||
s.FactionRead(
|
||||
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
||||
)
|
||||
for f in faction_service.list_factions(session)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/factions/{faction_id}", response_model=s.FactionRead)
|
||||
def rename_faction(
|
||||
faction_id: int,
|
||||
body: s.FactionRename,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.FactionRead:
|
||||
f = admin_service.rename_faction(session, faction_id, body.name_ru)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="update",
|
||||
entity_type="faction",
|
||||
entity_id=faction_id,
|
||||
payload={"name_ru": f.name_ru},
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return s.FactionRead(
|
||||
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/matches/{match_id}", response_model=s.OkResponse)
|
||||
def delete_match(
|
||||
match_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
admin_service.delete_match(session, match_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/audit-logs", response_model=s.AuditLogList)
|
||||
def audit_logs(
|
||||
action: str | None = Query(None),
|
||||
entity_type: str | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
return admin_service.list_audit_logs(
|
||||
session, action=action, entity_type=entity_type, limit=limit, offset=offset
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
||||
|
||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.login import login_with_identity
|
||||
from app.auth.registry import enabled_methods
|
||||
from app.auth.telegram import TelegramProvider
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.routers.users import build_me
|
||||
from app.schemas import api as s
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=s.AuthConfig)
|
||||
def auth_config() -> s.AuthConfig:
|
||||
return s.AuthConfig(
|
||||
methods=enabled_methods(),
|
||||
telegram_bot_username=settings.telegram_bot_username,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/telegram", response_model=s.MeRead)
|
||||
def telegram_login(
|
||||
body: s.TelegramAuthPayload,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
identity = TelegramProvider().authenticate(body.model_dump())
|
||||
user = login_with_identity(session, response, request, identity)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=s.OkResponse)
|
||||
def logout(response: Response) -> s.OkResponse:
|
||||
security.clear_user_session(response)
|
||||
return s.OkResponse()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||
|
||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
||||
входа по логину остаётся только на деве.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.dev_stub import DevStubProvider
|
||||
from app.auth.login import login_with_identity
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.routers.users import build_me
|
||||
from app.schemas import api as s
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/auth/dev", tags=["auth-dev"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=s.MeRead)
|
||||
def dev_login(
|
||||
body: s.DevLogin,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
identity = DevStubProvider().authenticate({"nickname": body.nickname})
|
||||
user = login_with_identity(session, response, request, identity)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[s.DevUserRead])
|
||||
def dev_list_users(session: Session = Depends(get_session)) -> list[s.DevUserRead]:
|
||||
users = session.exec(
|
||||
select(User).where(User.role == "player").order_by(User.nickname)
|
||||
).all()
|
||||
return [
|
||||
s.DevUserRead(id=u.id, nickname=u.nickname, is_active=u.is_active) # type: ignore[arg-type]
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/users", response_model=s.DevUserRead)
|
||||
def dev_create_user(
|
||||
body: s.DevUserCreate, session: Session = Depends(get_session)
|
||||
) -> s.DevUserRead:
|
||||
nickname = body.nickname.strip()
|
||||
identity = ExternalIdentity(provider="stub", external_id=nickname, suggested_nickname=nickname)
|
||||
user = user_service.get_or_create_from_identity(session, identity)
|
||||
return s.DevUserRead(id=user.id, nickname=user.nickname) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Группы, членство, доступные фракции, список партий и статистика группы."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import (
|
||||
audit_service,
|
||||
group_service,
|
||||
membership_service,
|
||||
stats_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
||||
group = group_service.get_group(session, group_id)
|
||||
member = group_service.assert_member(session, group_id, user_id)
|
||||
return s.GroupDetail(
|
||||
id=group.id, # type: ignore[arg-type]
|
||||
name=group.name,
|
||||
owner_id=group.owner_id,
|
||||
my_role=member.role,
|
||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[s.GroupBrief])
|
||||
def my_groups(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[s.GroupBrief]:
|
||||
return [
|
||||
s.GroupBrief(id=g.id, name=g.name, role=role)
|
||||
for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type]
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=s.GroupDetail)
|
||||
def create_group(
|
||||
body: s.GroupCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
group = group_service.create_group(session, user, body.name, body.expansion_ids)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="create",
|
||||
entity_type="group",
|
||||
entity_id=group.id,
|
||||
payload={"name": group.name},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return _detail(session, group.id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=s.GroupDetail)
|
||||
def get_group(
|
||||
group_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||
def rename_group(
|
||||
group_id: int,
|
||||
body: s.GroupRename,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
group_service.rename_group(session, group, body.name)
|
||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.put("/{group_id}/expansions", response_model=s.GroupDetail)
|
||||
def set_expansions(
|
||||
group_id: int,
|
||||
body: s.GroupExpansionsUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
group_service.set_expansions(session, group, body.expansion_ids)
|
||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.get("/{group_id}/factions", response_model=list[s.FactionRead])
|
||||
def group_factions(
|
||||
group_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[s.FactionRead]:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return [
|
||||
s.FactionRead(
|
||||
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
||||
)
|
||||
for f in group_service.available_factions(session, group_id)
|
||||
]
|
||||
|
||||
|
||||
# ─── Членство ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{group_id}/members", response_model=list[s.MemberRead])
|
||||
def list_members(
|
||||
group_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[s.MemberRead]:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return [
|
||||
s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
|
||||
for m, u in membership_service.list_members(session, group_id)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{group_id}/members", response_model=s.MemberRead)
|
||||
def add_member(
|
||||
group_id: int,
|
||||
body: s.MemberAdd,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MemberRead:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member, added = membership_service.add_member_by_nickname(session, group, body.nickname)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="create",
|
||||
entity_type="group_member",
|
||||
entity_id=group_id,
|
||||
payload={"added_user_id": added.id, "nickname": added.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role)
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse)
|
||||
def remove_member(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
# Любой участник управляет составом; защита создателя — в membership_service
|
||||
# (нельзя удалить последнего владельца).
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
membership_service.remove_member(session, group, user_id)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@router.patch("/{group_id}/members/{user_id}", response_model=s.MemberRead)
|
||||
def change_member_role(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
body: s.MemberRoleUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MemberRead:
|
||||
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member = membership_service.change_role(session, group, user_id, body.role)
|
||||
from app.services.user_service import get_user
|
||||
|
||||
u = get_user(session, user_id)
|
||||
return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
|
||||
|
||||
|
||||
# ─── Партии и статистика группы ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{group_id}/matches", response_model=s.MatchList)
|
||||
def group_matches(
|
||||
group_id: int,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return stats_service.group_match_list(session, group_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{group_id}/stats", response_model=s.GroupStats)
|
||||
def group_stats(
|
||||
group_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return stats_service.group_stats(session, group_id)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import NoGroupError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import Match, User
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service, group_service, match_service
|
||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||
|
||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||
|
||||
|
||||
def _ensure_has_any_group(session: Session, user: User) -> None:
|
||||
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
|
||||
raise NoGroupError()
|
||||
|
||||
|
||||
def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead:
|
||||
parts = [
|
||||
s.MatchParticipantRead(
|
||||
user_id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
faction_id=f.id, # type: ignore[arg-type]
|
||||
faction_name=f.name_ru,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||||
]
|
||||
return s.MatchRead(
|
||||
id=match.id, # type: ignore[arg-type]
|
||||
group_id=match.group_id,
|
||||
status=match.status,
|
||||
played_at=match.played_at,
|
||||
started_at=iso_utc(match.started_at),
|
||||
finished_at=iso_utc(match.finished_at),
|
||||
duration_minutes=match.duration_minutes,
|
||||
win_reason=match.win_reason, # type: ignore[arg-type]
|
||||
player_count=match.player_count,
|
||||
overall_comment=match.overall_comment,
|
||||
created_by=match.created_by,
|
||||
can_modify=can_modify,
|
||||
participants=parts,
|
||||
attachments=[],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/randomize-faction", response_model=s.RandomizeResponse)
|
||||
def randomize_faction(
|
||||
body: s.RandomizeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.RandomizeResponse:
|
||||
_ensure_has_any_group(session, user)
|
||||
group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type]
|
||||
faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids)
|
||||
return s.RandomizeResponse(
|
||||
faction=s.FactionRead(
|
||||
id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=s.MatchRead)
|
||||
def start_match(
|
||||
body: s.MatchCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MatchRead:
|
||||
"""Этап 1: старт партии (выбор игроков и фракций)."""
|
||||
_ensure_has_any_group(session, user)
|
||||
roster = [
|
||||
RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.create_match(session, user, group_id=body.group_id, roster=roster)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="create",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@router.post("/{match_id}/finish", response_model=s.MatchRead)
|
||||
def finish_match(
|
||||
match_id: int,
|
||||
body: s.MatchFinish,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MatchRead:
|
||||
"""Этап 2: завершение партии (места, причина победы, длительность)."""
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
finish = [
|
||||
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.finish_match(
|
||||
session,
|
||||
match,
|
||||
finish=finish,
|
||||
win_reason=body.win_reason,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@router.get("/{match_id}", response_model=s.MatchRead)
|
||||
def get_match(
|
||||
match_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@router.patch("/{match_id}", response_model=s.MatchRead)
|
||||
def update_match(
|
||||
match_id: int,
|
||||
body: s.MatchUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
played_at=body.played_at,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@router.delete("/{match_id}", response_model=s.OkResponse)
|
||||
def delete_match(
|
||||
match_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_id_val = match.id
|
||||
group_id_val = match.group_id
|
||||
match_service.delete_match(session, match)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="delete",
|
||||
entity_type="match",
|
||||
entity_id=match_id_val,
|
||||
payload={"group_id": group_id_val},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Справочники: дополнения и фракции (для всех авторизованных)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import faction_service
|
||||
|
||||
router = APIRouter(tags=["reference"])
|
||||
|
||||
|
||||
@router.get("/expansions", response_model=list[s.ExpansionRead])
|
||||
def list_expansions(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[s.ExpansionRead]:
|
||||
return [
|
||||
s.ExpansionRead(id=e.id, code=e.code, name_ru=e.name_ru, is_base=e.is_base) # type: ignore[arg-type]
|
||||
for e in faction_service.list_expansions(session)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/factions", response_model=list[s.FactionRead])
|
||||
def list_factions(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[s.FactionRead]:
|
||||
return [
|
||||
s.FactionRead(
|
||||
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
||||
)
|
||||
for f in faction_service.list_factions(session)
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Рейтинги и агрегат главной страницы."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import stats_service
|
||||
|
||||
router = APIRouter(tags=["stats"])
|
||||
|
||||
|
||||
@router.get("/stats/leaderboard", response_model=s.Leaderboard)
|
||||
def overall_leaderboard(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
board = stats_service.leaderboard(session, group_id=None)
|
||||
board["entries"] = board["entries"][:limit]
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/home", response_model=s.HomeResponse)
|
||||
def home(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return stats_service.home(session, user.id, user.active_group_id) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Роутер текущего пользователя."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service, group_service, stats_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def build_me(session: Session, user: User) -> s.MeRead:
|
||||
groups = [
|
||||
s.GroupBrief(id=g.id, name=g.name, role=role)
|
||||
for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type]
|
||||
]
|
||||
return s.MeRead(
|
||||
id=user.id, # type: ignore[arg-type]
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
auth_provider=user.auth_provider,
|
||||
telegram_id=user.telegram_id,
|
||||
active_group_id=user.active_group_id,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=s.MeRead)
|
||||
def get_me(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=s.UserRead)
|
||||
def update_me(
|
||||
body: s.NicknameUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.UserRead:
|
||||
user_service.update_nickname(session, user, body.nickname)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"nickname": user.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.UserRead(
|
||||
id=user.id, # type: ignore[arg-type]
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
auth_provider=user.auth_provider,
|
||||
telegram_id=user.telegram_id,
|
||||
active_group_id=user.active_group_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=s.ProfileStats)
|
||||
def my_stats(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return stats_service.profile_stats(session, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.put("/me/active-group", response_model=s.MeRead)
|
||||
def set_active_group(
|
||||
body: s.ActiveGroupUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
user_service.set_active_group(session, user, body.group_id)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.get("/nickname-available", response_model=s.NicknameAvailable)
|
||||
def nickname_available(
|
||||
value: str = Query(..., min_length=1),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.NicknameAvailable:
|
||||
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
||||
return s.NicknameAvailable(available=available)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
||||
methods: list[str] = []
|
||||
telegram_bot_username: str | None = None
|
||||
|
||||
|
||||
class TelegramAuthPayload(BaseModel):
|
||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: int
|
||||
auth_date: int
|
||||
hash: str
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
|
||||
|
||||
class DevLogin(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class DevUserCreate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class DevUserRead(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
# ─── Справочники ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ExpansionRead(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
is_base: bool
|
||||
|
||||
|
||||
class FactionRead(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
expansion_id: int
|
||||
|
||||
|
||||
class FactionRename(BaseModel):
|
||||
name_ru: str
|
||||
|
||||
|
||||
# ─── Пользователь ────────────────────────────────────────────────────────────
|
||||
|
||||
class GroupBrief(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
role: str
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
role: str
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
active_group_id: int | None = None
|
||||
|
||||
|
||||
class MeRead(UserRead):
|
||||
groups: list[GroupBrief] = []
|
||||
|
||||
|
||||
class NicknameUpdate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class ActiveGroupUpdate(BaseModel):
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class NicknameAvailable(BaseModel):
|
||||
available: bool
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class GroupRename(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class GroupExpansionsUpdate(BaseModel):
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class GroupDetail(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
owner_id: int
|
||||
my_role: str
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
user_id: int
|
||||
nickname: str
|
||||
role: str
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class MemberRoleUpdate(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class RandomizeRequest(BaseModel):
|
||||
group_id: int
|
||||
exclude_faction_ids: list[int] = []
|
||||
|
||||
|
||||
class RandomizeResponse(BaseModel):
|
||||
faction: FactionRead
|
||||
|
||||
|
||||
# Этап 1 (старт): только ростер, без мест.
|
||||
class RosterParticipant(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
was_random: bool = False
|
||||
|
||||
|
||||
class MatchCreate(BaseModel):
|
||||
group_id: int
|
||||
participants: list[RosterParticipant]
|
||||
|
||||
|
||||
# Этап 2 (завершение): места, комментарии, причина победы.
|
||||
class MatchFinishParticipant(BaseModel):
|
||||
user_id: int
|
||||
place: int = Field(ge=1)
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
|
||||
class MatchFinish(BaseModel):
|
||||
participants: list[MatchFinishParticipant]
|
||||
win_reason: WinReason
|
||||
overall_comment: str | None = None
|
||||
|
||||
|
||||
# Полный участник (правка завершённой партии админом).
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int = Field(ge=1)
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class MatchUpdate(BaseModel):
|
||||
played_at: date | None = None
|
||||
overall_comment: str | None = None
|
||||
win_reason: WinReason | None = None
|
||||
participants: list[ParticipantInput] | None = None
|
||||
|
||||
|
||||
class MatchParticipantRead(BaseModel):
|
||||
user_id: int
|
||||
nickname: str
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class MatchRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
status: str
|
||||
played_at: date
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
player_count: int
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
can_modify: bool = False # может ли текущий зритель править/завершать партию
|
||||
participants: list[MatchParticipantRead] = []
|
||||
attachments: list[Any] = [] # задел под вложения (всегда пусто в v1)
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
|
||||
class OverallStats(BaseModel):
|
||||
games: int
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
class LeaderboardEntry(OverallStats):
|
||||
user_id: int
|
||||
nickname: str
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
class Leaderboard(BaseModel):
|
||||
entries: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
min_games: int
|
||||
|
||||
|
||||
class FactionStat(BaseModel):
|
||||
faction_id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
expansion_code: str
|
||||
games: int
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
class RecentFormItem(BaseModel):
|
||||
place: int
|
||||
player_count: int
|
||||
played_at: str
|
||||
|
||||
|
||||
class ProfileStats(BaseModel):
|
||||
user_id: int
|
||||
overall: OverallStats
|
||||
factions: list[FactionStat] = []
|
||||
best_faction: FactionStat | None = None
|
||||
worst_faction: FactionStat | None = None
|
||||
most_played_faction: FactionStat | None = None
|
||||
recent_form: list[RecentFormItem] = []
|
||||
|
||||
|
||||
class FactionMeta(BaseModel):
|
||||
faction_id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
games: int
|
||||
wins: int
|
||||
available: bool
|
||||
|
||||
|
||||
class GroupStats(BaseModel):
|
||||
group_id: int
|
||||
total_matches: int
|
||||
last_match_at: str | None = None
|
||||
leaderboard: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
faction_meta: list[FactionMeta] = []
|
||||
min_games: int
|
||||
|
||||
|
||||
class MatchListParticipant(BaseModel):
|
||||
user_id: int
|
||||
nickname: str
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class MatchListItem(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
played_at: str
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
player_count: int
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
participants: list[MatchListParticipant] = []
|
||||
|
||||
|
||||
class MatchList(BaseModel):
|
||||
items: list[MatchListItem] = []
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class GroupBriefStats(OverallStats):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class HomeInProgressMatch(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
group_name: str
|
||||
started_at: str | None = None
|
||||
player_count: int
|
||||
participants: list[MatchListParticipant] = []
|
||||
|
||||
|
||||
class HomeResponse(BaseModel):
|
||||
leaderboard: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
profile: ProfileStats
|
||||
active_group: GroupBriefStats | None = None
|
||||
in_progress: list[HomeInProgressMatch] = []
|
||||
min_games: int
|
||||
|
||||
|
||||
# ─── Админ ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class AdminLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AdminMe(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
role: str
|
||||
|
||||
|
||||
class AdminUserRead(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
role: str
|
||||
is_active: bool
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminGroupRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
owner_id: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class AdminMatchRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
group_name: str | None = None
|
||||
status: str
|
||||
played_at: str
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
player_count: int
|
||||
created_by: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class AuditLogItem(BaseModel):
|
||||
id: int
|
||||
actor_id: int | None = None
|
||||
action: str
|
||||
entity_type: str
|
||||
entity_id: int | None = None
|
||||
payload: dict | None = None
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class AuditLogList(BaseModel):
|
||||
items: list[AuditLogItem] = []
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Справочные данные: дополнения и фракции. Идемпотентный сидинг по `code`."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Expansion, Faction
|
||||
|
||||
# (code, name_ru, is_base, sort_order)
|
||||
EXPANSIONS: list[tuple[str, str, bool, int]] = [
|
||||
("base", "Базовая игра", True, 0),
|
||||
("forgotten_worlds", "Forgotten Worlds", False, 1),
|
||||
("forsaken_voids", "Forsaken Voids", False, 2),
|
||||
]
|
||||
|
||||
# (code, name_ru, expansion_code, sort_order)
|
||||
FACTIONS: list[tuple[str, str, str, int]] = [
|
||||
# База
|
||||
("orks", "Орки", "base", 0),
|
||||
("ultramarines", "Ультрамарины", "base", 1),
|
||||
("eldar", "Эльдары", "base", 2),
|
||||
("chaos", "Хаоситы", "base", 3),
|
||||
# Forgotten Worlds
|
||||
("astra_militarum", "Имперская гвардия", "forgotten_worlds", 0),
|
||||
("tau", "Тау", "forgotten_worlds", 1),
|
||||
("necrons", "Некроны", "forgotten_worlds", 2),
|
||||
("tyranids", "Тираниды", "forgotten_worlds", 3),
|
||||
# Forsaken Voids
|
||||
("inquisition", "Инквизиция", "forsaken_voids", 0),
|
||||
("sisters_of_battle", "Сёстры битвы", "forsaken_voids", 1),
|
||||
("drukhari", "Друкхари", "forsaken_voids", 2),
|
||||
("adeptus_mechanicus", "Адептус Механикус", "forsaken_voids", 3),
|
||||
]
|
||||
|
||||
|
||||
def seed_reference_data(session: Session) -> None:
|
||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
||||
code_to_expansion: dict[str, Expansion] = {}
|
||||
|
||||
for code, name_ru, is_base, order in EXPANSIONS:
|
||||
exp = session.exec(select(Expansion).where(Expansion.code == code)).first()
|
||||
if exp is None:
|
||||
exp = Expansion(code=code, name_ru=name_ru, is_base=is_base, sort_order=order)
|
||||
session.add(exp)
|
||||
else:
|
||||
exp.name_ru = name_ru
|
||||
exp.is_base = is_base
|
||||
exp.sort_order = order
|
||||
code_to_expansion[code] = exp
|
||||
|
||||
session.flush() # получить id дополнений
|
||||
|
||||
for code, name_ru, exp_code, order in FACTIONS:
|
||||
expansion = code_to_expansion[exp_code]
|
||||
fac = session.exec(select(Faction).where(Faction.code == code)).first()
|
||||
if fac is None:
|
||||
fac = Faction(
|
||||
code=code,
|
||||
name_ru=name_ru,
|
||||
expansion_id=expansion.id, # type: ignore[arg-type]
|
||||
sort_order=order,
|
||||
)
|
||||
session.add(fac)
|
||||
else:
|
||||
fac.name_ru = name_ru
|
||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||
fac.sort_order = order
|
||||
|
||||
session.commit()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Админ: аутентификация (логин=ник + пароль) и управление сущностями."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError
|
||||
from app.core.security import verify_password
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
||||
|
||||
|
||||
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
||||
user = session.exec(
|
||||
select(User).where(
|
||||
User.nickname == username,
|
||||
User.role == "admin",
|
||||
User.auth_provider == "local",
|
||||
User.is_active == True, # noqa: E712
|
||||
)
|
||||
).first()
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError()
|
||||
return user
|
||||
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
def list_users(session: Session, query: str | None = None) -> list[User]:
|
||||
stmt = select(User).order_by(User.created_at.desc())
|
||||
if query:
|
||||
stmt = stmt.where(User.nickname.contains(query))
|
||||
return list(session.exec(stmt).all())
|
||||
|
||||
|
||||
def update_user(session: Session, user_id: int, *, nickname: str | None = None, is_active: bool | None = None) -> User:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
if nickname is not None:
|
||||
user.nickname = nickname.strip()
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def delete_user(session: Session, user_id: int) -> None:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_groups(session: Session) -> list[Group]:
|
||||
return list(session.exec(select(Group).order_by(Group.created_at.desc())).all())
|
||||
|
||||
|
||||
def delete_group(session: Session, group_id: int) -> None:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
session.delete(group)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_matches(
|
||||
session: Session,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
group_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
faction_id: int | None = None,
|
||||
) -> list[tuple[Match, str]]:
|
||||
"""Возвращает партии вместе с названием группы (для группировки в админке).
|
||||
|
||||
Фильтры: по группе, по игроку-участнику, по фракции участника.
|
||||
"""
|
||||
stmt = (
|
||||
select(Match, Group.name)
|
||||
.join(Group, Group.id == Match.group_id)
|
||||
.order_by(Match.group_id, Match.played_at.desc(), Match.id.desc())
|
||||
)
|
||||
if group_id is not None:
|
||||
stmt = stmt.where(Match.group_id == group_id)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(
|
||||
Match.id.in_(
|
||||
select(MatchParticipant.match_id).where(MatchParticipant.user_id == user_id)
|
||||
)
|
||||
)
|
||||
if faction_id is not None:
|
||||
stmt = stmt.where(
|
||||
Match.id.in_(
|
||||
select(MatchParticipant.match_id).where(
|
||||
MatchParticipant.faction_id == faction_id
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
||||
return [(mt, gname) for mt, gname in rows]
|
||||
|
||||
|
||||
def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
|
||||
"""Переименовывает фракцию во всей системе (имя хранится один раз)."""
|
||||
name_ru = (name_ru or "").strip()
|
||||
if not (1 <= len(name_ru) <= 64):
|
||||
raise ValidationError("Название фракции: 1–64 символа.")
|
||||
faction = session.get(Faction, faction_id)
|
||||
if faction is None:
|
||||
raise NotFoundError("Фракция не найдена.")
|
||||
faction.name_ru = name_ru
|
||||
session.add(faction)
|
||||
session.commit()
|
||||
session.refresh(faction)
|
||||
return faction
|
||||
|
||||
|
||||
def delete_match(session: Session, match_id: int) -> None:
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
session.delete(match)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
def list_audit_logs(
|
||||
session: Session,
|
||||
*,
|
||||
action: str | None = None,
|
||||
entity_type: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
if action:
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
if entity_type:
|
||||
stmt = stmt.where(AuditLog.entity_type == entity_type)
|
||||
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"actor_id": r.actor_id,
|
||||
"action": r.action,
|
||||
"entity_type": r.entity_type,
|
||||
"entity_id": r.entity_id,
|
||||
"payload": r.payload,
|
||||
"ip": r.ip,
|
||||
"user_agent": r.user_agent,
|
||||
"created_at": iso_utc(r.created_at),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Журнал аудита: запись действий, изменяющих состояние."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
def record(
|
||||
session: Session,
|
||||
*,
|
||||
actor_id: int | None,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Добавляет запись аудита в сессию (commit — на стороне вызывающего)."""
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
payload=payload,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Справочники: дополнения и фракции."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Expansion, Faction
|
||||
|
||||
|
||||
def list_expansions(session: Session) -> list[Expansion]:
|
||||
return list(
|
||||
session.exec(select(Expansion).order_by(Expansion.sort_order)).all()
|
||||
)
|
||||
|
||||
|
||||
def list_factions(session: Session) -> list[Faction]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||
).all()
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Группы: создание, дополнения, доступные фракции, проверки доступа."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import NotFoundError, NotGroupMemberError, ForbiddenError, ValidationError
|
||||
from app.models import (
|
||||
Expansion,
|
||||
Faction,
|
||||
Group,
|
||||
GroupExpansion,
|
||||
GroupMember,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def get_group(session: Session, group_id: int) -> Group:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
return group
|
||||
|
||||
|
||||
def get_membership(session: Session, group_id: int, user_id: int) -> GroupMember | None:
|
||||
return session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
def assert_member(session: Session, group_id: int, user_id: int) -> GroupMember:
|
||||
member = get_membership(session, group_id, user_id)
|
||||
if member is None:
|
||||
raise NotGroupMemberError()
|
||||
return member
|
||||
|
||||
|
||||
def assert_owner(session: Session, group_id: int, user_id: int) -> GroupMember:
|
||||
member = assert_member(session, group_id, user_id)
|
||||
if member.role != "owner":
|
||||
raise ForbiddenError("Действие доступно только владельцу группы.")
|
||||
return member
|
||||
|
||||
|
||||
def list_user_groups(session: Session, user_id: int) -> list[tuple[Group, str]]:
|
||||
rows = session.exec(
|
||||
select(Group, GroupMember.role)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.where(GroupMember.user_id == user_id)
|
||||
.order_by(Group.name)
|
||||
).all()
|
||||
return [(g, role) for g, role in rows]
|
||||
|
||||
|
||||
def _valid_non_base_expansion_ids(session: Session, expansion_ids: list[int]) -> list[int]:
|
||||
if not expansion_ids:
|
||||
return []
|
||||
found = session.exec(
|
||||
select(Expansion.id).where(
|
||||
Expansion.id.in_(expansion_ids), Expansion.is_base == False # noqa: E712
|
||||
)
|
||||
).all()
|
||||
return list(found)
|
||||
|
||||
|
||||
def create_group(session: Session, owner: User, name: str, expansion_ids: list[int]) -> Group:
|
||||
name = (name or "").strip()
|
||||
if not (2 <= len(name) <= 64):
|
||||
raise ValidationError("Название группы: 2–64 символа.")
|
||||
|
||||
group = Group(name=name, owner_id=owner.id) # type: ignore[arg-type]
|
||||
session.add(group)
|
||||
session.flush()
|
||||
|
||||
session.add(GroupMember(group_id=group.id, user_id=owner.id, role="owner")) # type: ignore[arg-type]
|
||||
|
||||
for exp_id in _valid_non_base_expansion_ids(session, expansion_ids):
|
||||
session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type]
|
||||
|
||||
owner.active_group_id = group.id
|
||||
session.add(owner)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def rename_group(session: Session, group: Group, name: str) -> Group:
|
||||
name = (name or "").strip()
|
||||
if not (2 <= len(name) <= 64):
|
||||
raise ValidationError("Название группы: 2–64 символа.")
|
||||
group.name = name
|
||||
session.add(group)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||
current = session.exec(
|
||||
select(GroupExpansion).where(GroupExpansion.group_id == group.id)
|
||||
).all()
|
||||
current_ids = {ge.expansion_id for ge in current}
|
||||
|
||||
for ge in current:
|
||||
if ge.expansion_id not in valid:
|
||||
session.delete(ge)
|
||||
for exp_id in valid - current_ids:
|
||||
session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type]
|
||||
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def group_expansion_ids(session: Session, group_id: int) -> list[int]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def available_factions(session: Session, group_id: int) -> list[Faction]:
|
||||
owned = select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id)
|
||||
stmt = (
|
||||
select(Faction)
|
||||
.join(Expansion, Expansion.id == Faction.expansion_id)
|
||||
.where(or_(Expansion.is_base == True, Expansion.id.in_(owned))) # noqa: E712
|
||||
.order_by(Expansion.sort_order, Faction.sort_order)
|
||||
)
|
||||
return list(session.exec(stmt).all())
|
||||
|
||||
|
||||
def available_faction_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {f.id for f in available_factions(session, group_id)} # type: ignore[misc]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
DuplicateParticipantError,
|
||||
FactionNotAvailableError,
|
||||
ForbiddenError,
|
||||
InvalidRankingError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.timeutil import app_today
|
||||
from app.models import Faction, GroupMember, Match, MatchParticipant, User
|
||||
from app.services import group_service
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RosterInput:
|
||||
"""Участник на этапе старта (мест ещё нет)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
was_random: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinishInput:
|
||||
"""Результат участника на этапе завершения."""
|
||||
|
||||
user_id: int
|
||||
place: int
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticipantInput:
|
||||
"""Полный участник (для правки завершённой партии админом)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
"""Округление длительности до получаса, минимум 30 минут."""
|
||||
return max(30, int(round(minutes / 30.0)) * 30)
|
||||
|
||||
|
||||
def get_match(session: Session, match_id: int) -> Match:
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
return match
|
||||
|
||||
|
||||
def participants_detail(
|
||||
session: Session, match_id: int
|
||||
) -> list[tuple[MatchParticipant, User, Faction]]:
|
||||
rows = session.exec(
|
||||
select(MatchParticipant, User, Faction)
|
||||
.join(User, User.id == MatchParticipant.user_id)
|
||||
.join(Faction, Faction.id == MatchParticipant.faction_id)
|
||||
.where(MatchParticipant.match_id == match_id)
|
||||
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
|
||||
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
|
||||
).all()
|
||||
return [(p, u, f) for p, u, f in rows]
|
||||
|
||||
|
||||
def randomize_faction(
|
||||
session: Session, group_id: int, exclude_faction_ids: list[int] | None = None
|
||||
) -> Faction:
|
||||
exclude = set(exclude_faction_ids or [])
|
||||
pool = [f for f in group_service.available_factions(session, group_id) if f.id not in exclude]
|
||||
if not pool:
|
||||
raise ValidationError("Нет доступных фракций для рандома.")
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
def _validate_ranking(places: list[int]) -> None:
|
||||
"""Проверяет competition ranking (1,2,2,4) с допуском ничьих."""
|
||||
if any(p < 1 for p in places):
|
||||
raise InvalidRankingError("Место должно быть ≥ 1.")
|
||||
ordered = sorted(places)
|
||||
expected = 1
|
||||
i = 0
|
||||
n = len(ordered)
|
||||
while i < n:
|
||||
current = ordered[i]
|
||||
if current != expected:
|
||||
raise InvalidRankingError(
|
||||
"Места должны идти по правилу 1,2,2,4 (без пропусков перед группой ничьих)."
|
||||
)
|
||||
tie = 0
|
||||
while i < n and ordered[i] == current:
|
||||
tie += 1
|
||||
i += 1
|
||||
expected = current + tie
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
for m in session.exec(
|
||||
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
def _validate_roster_basics(
|
||||
session: Session,
|
||||
group_id: int,
|
||||
user_ids: list[int],
|
||||
faction_ids: list[int],
|
||||
) -> None:
|
||||
if len(user_ids) < 2:
|
||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
||||
raise ValidationError("Все участники должны состоять в группе.")
|
||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
|
||||
# ─── Этап 1: старт партии ─────────────────────────────────────────────────────
|
||||
|
||||
def create_match(
|
||||
session: Session,
|
||||
creator: User,
|
||||
*,
|
||||
group_id: int,
|
||||
roster: list[RosterInput],
|
||||
) -> Match:
|
||||
group_service.assert_member(session, group_id, creator.id) # type: ignore[arg-type]
|
||||
_validate_roster_basics(
|
||||
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
match = Match(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||
started_at=now,
|
||||
player_count=len(roster),
|
||||
created_by=creator.id, # type: ignore[arg-type]
|
||||
)
|
||||
session.add(match)
|
||||
session.flush()
|
||||
|
||||
for r in roster:
|
||||
session.add(
|
||||
MatchParticipant(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=r.user_id,
|
||||
faction_id=r.faction_id,
|
||||
place=None,
|
||||
was_random=r.was_random,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
# ─── Этап 2: завершение партии ────────────────────────────────────────────────
|
||||
|
||||
def finish_match(
|
||||
session: Session,
|
||||
match: Match,
|
||||
*,
|
||||
finish: list[FinishInput],
|
||||
win_reason: str,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
) -> Match:
|
||||
if match.status != "in_progress":
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
if win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Укажите корректную причину победы.")
|
||||
|
||||
existing = {
|
||||
p.user_id: p
|
||||
for p in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
}
|
||||
if {f.user_id for f in finish} != set(existing.keys()):
|
||||
raise ValidationError("Нужно указать результат по всем участникам партии.")
|
||||
|
||||
# Эффективные фракции (с учётом возможной замены при завершении).
|
||||
eff_factions = {
|
||||
f.user_id: (f.faction_id if f.faction_id is not None else existing[f.user_id].faction_id)
|
||||
for f in finish
|
||||
}
|
||||
fids = list(eff_factions.values())
|
||||
if len(set(fids)) != len(fids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
_validate_ranking([f.place for f in finish])
|
||||
|
||||
for f in finish:
|
||||
p = existing[f.user_id]
|
||||
p.place = f.place
|
||||
p.comment = f.comment or None
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
session.add(p)
|
||||
|
||||
now = _utcnow()
|
||||
match.finished_at = now
|
||||
started = match.started_at
|
||||
if started is not None:
|
||||
if started.tzinfo is not None:
|
||||
started = started.replace(tzinfo=None)
|
||||
# max(0, ...) на случай перекоса часов — не уходим в отрицательную длительность.
|
||||
elapsed_min = max(0.0, (now.replace(tzinfo=None) - started).total_seconds() / 60.0)
|
||||
match.duration_minutes = round_to_30(elapsed_min)
|
||||
match.status = "finished"
|
||||
match.win_reason = win_reason
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
|
||||
session.add(match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
# ─── Права / правка / удаление ────────────────────────────────────────────────
|
||||
|
||||
def can_modify(session: Session, match: Match, user: User) -> bool:
|
||||
# Управление партиями доступно любому участнику группы (а также админу).
|
||||
if user.role == "admin":
|
||||
return True
|
||||
member = group_service.get_membership(session, match.group_id, user.id) # type: ignore[arg-type]
|
||||
return member is not None
|
||||
|
||||
|
||||
def assert_can_modify(session: Session, match: Match, user: User) -> None:
|
||||
if not can_modify(session, match, user):
|
||||
raise ForbiddenError("Недостаточно прав для изменения партии.")
|
||||
|
||||
|
||||
def update_match(
|
||||
session: Session,
|
||||
match: Match,
|
||||
*,
|
||||
played_at: date | None = None,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
win_reason: str | None = None,
|
||||
win_reason_set: bool = False,
|
||||
participants: list[ParticipantInput] | None = None,
|
||||
) -> Match:
|
||||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
match.win_reason = win_reason
|
||||
|
||||
if participants is not None:
|
||||
_validate_roster_basics(
|
||||
session,
|
||||
match.group_id,
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
)
|
||||
_validate_ranking([p.place for p in participants])
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
session.add(
|
||||
MatchParticipant(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
)
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
session.add(match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def delete_match(session: Session, match: Match) -> None:
|
||||
session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
|
||||
session.commit()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Членство в группе: список, добавление по нику, удаление, смена роли."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from app.models import Group, GroupMember, User
|
||||
|
||||
MAX_GROUP_SIZE = 10
|
||||
|
||||
|
||||
def list_members(session: Session, group_id: int) -> list[tuple[GroupMember, User]]:
|
||||
rows = session.exec(
|
||||
select(GroupMember, User)
|
||||
.join(User, User.id == GroupMember.user_id)
|
||||
.where(GroupMember.group_id == group_id)
|
||||
.order_by(GroupMember.role.desc(), User.nickname)
|
||||
).all()
|
||||
return [(m, u) for m, u in rows]
|
||||
|
||||
|
||||
def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tuple[GroupMember, User]:
|
||||
nickname = (nickname or "").strip()
|
||||
if not nickname:
|
||||
raise ValidationError("Укажите никнейм игрока.")
|
||||
user = session.exec(select(User).where(User.nickname == nickname)).first()
|
||||
if user is None:
|
||||
raise NotFoundError("Игрок с таким ником не найден.")
|
||||
|
||||
member_count = len(
|
||||
session.exec(select(GroupMember.id).where(GroupMember.group_id == group.id)).all()
|
||||
)
|
||||
if member_count >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
||||
|
||||
existing = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
raise ConflictError("Игрок уже в группе.")
|
||||
|
||||
member = GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
return member, user
|
||||
|
||||
|
||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if member.role == "owner":
|
||||
owners = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
||||
|
||||
# Сбросить активную группу у тех, для кого она была активной.
|
||||
user = session.get(User, user_id)
|
||||
if user is not None and user.active_group_id == group.id:
|
||||
user.active_group_id = None
|
||||
session.add(user)
|
||||
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
|
||||
|
||||
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
|
||||
if role not in ("owner", "member"):
|
||||
raise ValidationError("Недопустимая роль.")
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
member.role = role
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
return member
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Метрика рейтинга. Вынесена отдельно — легко заменить.
|
||||
|
||||
По умолчанию: League Points — нормированные очки за место с учётом размера стола
|
||||
и ничьих (competition ranking). За партию из N игроков:
|
||||
points = (N - place - (tie_size - 1)/2) / (N - 1)
|
||||
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
|
||||
Рейтинговый счёт игрока = AVG(points) * 100.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||
MIN_GAMES = 3
|
||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||
FACTION_MIN_GAMES = 2
|
||||
|
||||
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
|
||||
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
|
||||
MATCH_POINTS_SQL = (
|
||||
"CASE WHEN m.player_count > 1 "
|
||||
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||||
"/ (m.player_count - 1) "
|
||||
"ELSE 1.0 END"
|
||||
)
|
||||
|
||||
|
||||
def leaderboard_sort_key(row: dict) -> tuple:
|
||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||
return (
|
||||
-(row["score"] or 0.0),
|
||||
-(row["win_rate"] or 0.0),
|
||||
-(row["games"] or 0),
|
||||
(row["avg_place"] or 0.0),
|
||||
row["nickname"].lower(),
|
||||
)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupMember, Match
|
||||
from app.services import group_service
|
||||
from app.services.scoring import (
|
||||
FACTION_MIN_GAMES,
|
||||
MATCH_POINTS_SQL,
|
||||
MIN_GAMES,
|
||||
leaderboard_sort_key,
|
||||
)
|
||||
|
||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
|
||||
SCORED_CTE = f"""
|
||||
WITH tie AS (
|
||||
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
WHERE m.status = 'finished' AND mp.place IS NOT NULL
|
||||
GROUP BY mp.match_id, mp.place
|
||||
),
|
||||
scored AS (
|
||||
SELECT mp.user_id AS user_id,
|
||||
mp.faction_id AS faction_id,
|
||||
m.id AS match_id,
|
||||
m.group_id AS group_id,
|
||||
m.played_at AS played_at,
|
||||
mp.place AS place,
|
||||
m.player_count AS player_count,
|
||||
({MATCH_POINTS_SQL}) AS points,
|
||||
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
|
||||
WHERE m.status = 'finished'
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _round(value: Any, ndigits: int) -> float | None:
|
||||
return None if value is None else round(float(value), ndigits)
|
||||
|
||||
|
||||
def _normalize(row: dict) -> dict:
|
||||
return {
|
||||
"user_id": row["user_id"],
|
||||
"nickname": row["nickname"],
|
||||
"games": int(row["games"] or 0),
|
||||
"wins": int(row["wins"] or 0),
|
||||
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(row["avg_place"], 2),
|
||||
"score": _round(row["score"], 1),
|
||||
}
|
||||
|
||||
|
||||
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
||||
where = "WHERE s.group_id = :gid" if group_id is not None else ""
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
{where}
|
||||
GROUP BY u.id, u.nickname
|
||||
"""
|
||||
params = {"gid": group_id} if group_id is not None else {}
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [_normalize(dict(r)) for r in result]
|
||||
|
||||
|
||||
def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
rows = _leaderboard_rows(session, group_id)
|
||||
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
|
||||
provisional = [r for r in rows if r["games"] < MIN_GAMES]
|
||||
qualified.sort(key=leaderboard_sort_key)
|
||||
provisional.sort(key=leaderboard_sort_key)
|
||||
for i, r in enumerate(qualified, start=1):
|
||||
r["rank"] = i
|
||||
for r in provisional:
|
||||
r["rank"] = None
|
||||
return {
|
||||
"entries": qualified,
|
||||
"provisional": provisional,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
{cond}
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
r = session.execute(text(sql), params).mappings().first() or {}
|
||||
return {
|
||||
"games": int(r.get("games") or 0),
|
||||
"wins": int(r.get("wins") or 0),
|
||||
"win_rate": _round(r.get("win_rate") or 0.0, 4),
|
||||
"avg_place": _round(r.get("avg_place"), 2),
|
||||
"score": _round(r.get("score"), 1),
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN factions f ON f.id = s.faction_id
|
||||
JOIN expansions e ON e.id = f.expansion_id
|
||||
{cond}
|
||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
||||
ORDER BY games DESC, score DESC
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
out = []
|
||||
for r in result:
|
||||
out.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"expansion_code": r["expansion_code"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(r["avg_place"], 2),
|
||||
"score": _round(r["score"], 1),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
|
||||
FROM scored s
|
||||
{cond}
|
||||
ORDER BY s.played_at DESC, s.match_id DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id, "lim": limit}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [
|
||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
||||
for r in result
|
||||
]
|
||||
|
||||
|
||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
||||
overall = _overall_for_user(session, user_id, group_id)
|
||||
factions = _faction_breakdown(session, user_id, group_id)
|
||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
most_played = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall": overall,
|
||||
"factions": factions,
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
}
|
||||
|
||||
|
||||
def group_stats(session: Session, group_id: int) -> dict:
|
||||
board = leaderboard(session, group_id=group_id)
|
||||
total_matches = session.exec(
|
||||
select(Match).where(Match.group_id == group_id, Match.status == "finished")
|
||||
).all()
|
||||
last_at = None
|
||||
if total_matches:
|
||||
last_at = str(max(m.played_at for m in total_matches))
|
||||
|
||||
available_ids = group_service.available_faction_ids(session, group_id)
|
||||
faction_meta = []
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
|
||||
FROM factions f
|
||||
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||
GROUP BY f.id, f.code, f.name_ru
|
||||
ORDER BY games DESC, f.sort_order
|
||||
"""
|
||||
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||
for r in rows:
|
||||
faction_meta.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"available": r["faction_id"] in available_ids,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"last_match_at": last_at,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
"faction_meta": faction_meta,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(Match.group_id == group_id)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for m in matches:
|
||||
parts = []
|
||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
||||
parts.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"status": m.status,
|
||||
"played_at": str(m.played_at),
|
||||
"started_at": iso_utc(m.started_at),
|
||||
"finished_at": iso_utc(m.finished_at),
|
||||
"duration_minutes": m.duration_minutes,
|
||||
"win_reason": m.win_reason,
|
||||
"player_count": m.player_count,
|
||||
"overall_comment": m.overall_comment,
|
||||
"created_by": m.created_by,
|
||||
"participants": parts,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
group_ids = list(
|
||||
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
||||
)
|
||||
if not group_ids:
|
||||
return []
|
||||
rows = session.exec(
|
||||
select(Match, Group.name)
|
||||
.join(Group, Group.id == Match.group_id)
|
||||
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
|
||||
.order_by(Match.started_at.desc(), Match.id.desc())
|
||||
).all()
|
||||
out = []
|
||||
for m, gname in rows:
|
||||
parts = [
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
|
||||
]
|
||||
out.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"group_id": m.group_id,
|
||||
"group_name": gname,
|
||||
"started_at": iso_utc(m.started_at),
|
||||
"player_count": m.player_count,
|
||||
"participants": parts,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||
board = leaderboard(session, group_id=None)
|
||||
profile = profile_stats(session, user_id, group_id=None)
|
||||
active_group_brief = None
|
||||
if active_group_id is not None:
|
||||
group = session.get(Group, active_group_id)
|
||||
if group is not None:
|
||||
active_group_brief = {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
**_overall_for_user(session, user_id, active_group_id),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
"provisional": board["provisional"][:leaderboard_limit],
|
||||
"profile": profile,
|
||||
"active_group": active_group_brief,
|
||||
"in_progress": user_in_progress_matches(session, user_id),
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||
from app.models import AuthIdentity, GroupMember, User
|
||||
|
||||
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
||||
|
||||
|
||||
def get_user(session: Session, user_id: int) -> User:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
return user
|
||||
|
||||
|
||||
def nickname_available(session: Session, nickname: str, exclude_user_id: int | None = None) -> bool:
|
||||
stmt = select(User).where(User.nickname == nickname)
|
||||
existing = session.exec(stmt).first()
|
||||
return existing is None or existing.id == exclude_user_id
|
||||
|
||||
|
||||
def _unique_nickname(session: Session, base: str) -> str:
|
||||
base = (base or "Игрок").strip()[:60] or "Игрок"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while session.exec(select(User).where(User.nickname == candidate)).first() is not None:
|
||||
suffix += 1
|
||||
candidate = f"{base} {suffix}"
|
||||
return candidate
|
||||
|
||||
|
||||
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
||||
link = session.exec(
|
||||
select(AuthIdentity).where(
|
||||
AuthIdentity.provider == identity.provider,
|
||||
AuthIdentity.external_id == identity.external_id,
|
||||
)
|
||||
).first()
|
||||
if link is not None:
|
||||
return get_user(session, link.user_id)
|
||||
|
||||
user = User(
|
||||
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id),
|
||||
role="player",
|
||||
auth_provider=identity.provider,
|
||||
telegram_id=identity.telegram_id,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
session.add(
|
||||
AuthIdentity(
|
||||
user_id=user.id, # type: ignore[arg-type]
|
||||
provider=identity.provider,
|
||||
external_id=identity.external_id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||
new_nickname = (new_nickname or "").strip()
|
||||
if not _NICK_RE.match(new_nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
||||
raise NicknameTakenError()
|
||||
user.nickname = new_nickname
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def set_active_group(session: Session, user: User, group_id: int | None) -> User:
|
||||
if group_id is not None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
||||
user.active_group_id = group_id
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
Reference in New Issue
Block a user